diff --git a/NEWS.md b/NEWS.md
--- a/NEWS.md
+++ b/NEWS.md
@@ -3,6 +3,57 @@
 
 
 
+funbot 0.5 -- 2016-01-27
+========================
+
+General, build and documentation changes:
+
+* Move the code from `String` everywhere into strict `Text`, often wrapped in
+  dedicated semantic `newtype`s for more type safety (since so many values are
+  just text otherwise) and for readability
+
+New UIs, features and enhancements:
+
+* Send history-on-join as a notice
+* Log web source errors into a file instead of stdout
+* Merge request announcements mention repo name
+* Add issue ext event announcements
+* Add note ext event announcements
+* Return an HTTP response to gitlab web hook, it seems to want it
+* TLS now implemented in `irc-fun-client`
+* Key-URL map for which link references
+* Puppet system for relaying arbitrary messages
+* More goodies... :)
+
+Bug fixes:
+
+* Git repo names and usernames are now compared in a case-insensitive manner,
+  which allows them to be specified in a human-friendly form while also
+  accepting all-lowercase versions used in generated URLs. Previously this
+  caused issues with !add-repo because of hacks made in an attempt to make
+  hashmaps pseudo-ignore letter case.
+* Display and store repo IDs as `user/repo` and not `repo/user`. The latter
+  seems to be confusing since git repo URLs use the former.
+* When `!get`ing a settings section, display the section and option lists in
+  sorted order. It's consistent and easier to find items with the eyes.
+
+Dependency changes:
+
+* Add     case-insensitive   >= 1
+          formatting         >= 6.2
+          hashable
+          irc-fun-client     >= 0.5
+          irc-fun-types      <0.2
+* Require funbot-ext-events  >= 0.3
+          irc-fun-bot        >= 0.6
+          irc-fun-color      >= 0.2
+          settings           >= 0.3
+          vcs-web-hook-parse >= 0.2
+
+
+
+
+
 funbot 0.4.0.1 -- 2015-12-17
 ============================
 
diff --git a/funbot.cabal b/funbot.cabal
--- a/funbot.cabal
+++ b/funbot.cabal
@@ -1,5 +1,5 @@
 name:                funbot
-version:             0.4.0.1
+version:             0.5
 synopsis:            IRC bot for fun, learning, creativity and collaboration.
 description:
   One day an idea came up on the #freepost IRC channel: We didn't need much of
@@ -48,13 +48,38 @@
 executable funbot
   main-is:             Main.hs
   other-modules:       FunBot.Commands
+                     , FunBot.Commands.Channels
+                     , FunBot.Commands.Feeds
+                     , FunBot.Commands.History
+                     , FunBot.Commands.Info
+                     , FunBot.Commands.Locations
+                     , FunBot.Commands.Memos
+                     , FunBot.Commands.Misc
+                     , FunBot.Commands.Puppet
+                     , FunBot.Commands.Repos
+                     , FunBot.Commands.Settings
+                     , FunBot.Commands.Shortcuts
+                     , FunBot.Commands.UserOptions
                      , FunBot.Config
                      , FunBot.ExtHandlers
                      , FunBot.History
                      , FunBot.IrcHandlers
                      , FunBot.KnownNicks
+                     , FunBot.Locations
                      , FunBot.Memos
+                     , FunBot.Puppet
                      , FunBot.Settings
+                     , FunBot.Settings.Help
+                     , FunBot.Settings.Instances
+                     , FunBot.Settings.MkOption
+                     , FunBot.Settings.Sections
+                     , FunBot.Settings.Sections.Channels
+                     , FunBot.Settings.Sections.DevHosts
+                     , FunBot.Settings.Sections.Feeds
+                     , FunBot.Settings.Sections.Locations
+                     , FunBot.Settings.Sections.Repos
+                     , FunBot.Settings.Sections.Shortcuts
+                     , FunBot.Settings.Persist
                      , FunBot.Sources
                      , FunBot.Sources.FeedWatcher
                      , FunBot.Sources.Loopback
@@ -62,6 +87,7 @@
                      , FunBot.Sources.WebListener.Client
                      , FunBot.Sources.WebListener.Gogs
                      , FunBot.Sources.WebListener.GitLab
+                     , FunBot.Sources.WebListener.Util
                      , FunBot.Types
                      , FunBot.UserOptions
                      , FunBot.Util
@@ -70,21 +96,26 @@
                      , auto-update
                      , base                 >=4.7 && <5
                      , bytestring
+                     , case-insensitive     >=1
                      , clock                >=0.5
                      , containers           >=0.5
                      , data-default-class
                      , feed
                      , feed-collect         >=0.2
-                     , funbot-ext-events    >=0.2
+                     , formatting           >=6.2
+                     , funbot-ext-events    >=0.3
+                     , hashable
                      , HTTP
                      , http-client          >=0.4.19
                      , http-client-tls      >=0.2.2
                      , http-listen
-                     , irc-fun-bot          >=0.5 && <0.6
-                     , irc-fun-color
+                     , irc-fun-bot          >=0.6 && <0.7
+                     , irc-fun-client       >=0.5 && <0.6
+                     , irc-fun-color        >=0.2 && <0.3
+                     , irc-fun-types        <0.2
                      , json-state
                      , network-uri
-                     , settings             >=0.2.2
+                     , settings             >=0.3
                      , tagsoup              >=0.13
                      , text
                      , time
@@ -93,6 +124,6 @@
                      , transformers
                      , unordered-containers >=0.2.5
                      , utf8-string          >=1
-                     , vcs-web-hook-parse
+                     , vcs-web-hook-parse   >=0.2
   hs-source-dirs:      src
   default-language:    Haskell2010
diff --git a/src/FunBot/Commands.hs b/src/FunBot/Commands.hs
--- a/src/FunBot/Commands.hs
+++ b/src/FunBot/Commands.hs
@@ -1,6 +1,6 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
  -
  - ♡ Copying is an act of love. Please copy, reuse and share.
  -
@@ -13,26 +13,27 @@
  - <http://creativecommons.org/publicdomain/zero/1.0/>.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module FunBot.Commands
     ( commandSet
     )
 where
 
-import Control.Monad (unless, when)
-import Data.List (find, intercalate)
-import Data.Settings.Types (showOption)
-import FunBot.History (quote, reportHistory')
-import FunBot.Memos (submitMemo)
-import FunBot.Settings
-import FunBot.Types (BotSession)
-import FunBot.UserOptions
-import FunBot.Util (getHistoryLines)
-import Network.IRC.Fun.Bot.Behavior
-import Network.IRC.Fun.Bot.Chat
-import Network.IRC.Fun.Bot.State
+import FunBot.Commands.Channels
+import FunBot.Commands.Feeds
+import FunBot.Commands.History
+import FunBot.Commands.Info
+import FunBot.Commands.Locations
+import FunBot.Commands.Memos
+import FunBot.Commands.Misc
+import FunBot.Commands.Puppet
+import FunBot.Commands.Repos
+import FunBot.Commands.Settings
+import FunBot.Commands.Shortcuts
+import FunBot.Commands.UserOptions
+import FunBot.Types
 import Network.IRC.Fun.Bot.Types
-import Text.Printf (printf)
-import Text.Read (readMaybe)
 
 -- | The main command set, the only one currently
 commandSet = CommandSet
@@ -66,706 +67,16 @@
         , cmdShowHistory
         , cmdAddFeed
         , cmdDeleteFeed
+        , cmdWhere
+        , cmdWhereLocal
+        , cmdWhereGlobal
+        , cmdAddWhereLocal
+        , cmdRemoveWhereLocal
+        , cmdAddWhereGlobal
+        , cmdRemoveWhereGlobal
+        , cmdPuppetStart
+        , cmdPuppetEnd
+        , cmdPuppetSay
+        , cmdPuppetEcho
         ]
-    }
-
--------------------------------------------------------------------------------
--- Echo command
--- Send the input back to the IRC channel
--------------------------------------------------------------------------------
-
-respondEcho _mchan _nick []      send = send " "
-respondEcho _mchan _nick [param] send = send param
-respondEcho _mchan _nick params  send = send $ unwords params
-
-cmdEcho = Command
-    { cmdNames   = ["echo"]
-    , cmdRespond = respondEcho
-    , cmdHelp    = "‘echo <text>’ - display the given text. Probably not a \
-                   \useful command. Exists as an example and for testing."
-    }
-
--------------------------------------------------------------------------------
--- Help command
--- Show command help strings
--------------------------------------------------------------------------------
-
-respondHelp _mchan _nick [cname] send =
-    case find ((cname' `elem`) . cmdNames) $ csetCommands commandSet of
-        Just cmd -> send $ cmdHelp cmd
-                        ++ "\nCommand names: "
-                        ++ listNames Nothing Nothing True (cmdNames cmd)
-        Nothing  -> do
-            succ <- respondSettingsHelp cname send
-            unless succ $ send
-                "No such command, or invalid settings path. \
-                \Maybe try just ‘!help’ without a parameter."
-    where
-    cname' = case cname of
-        []     -> cname
-        (c:cs) -> if c == csetPrefix commandSet then cs else cname
-
-respondHelp _mchan nick _params send =
-     send "!info intro    - about me\n\
-          \!info commands - how to ask me to do things\n\
-          \!info          - list of help/information topics\n\
-          \!help help     - how to use this command\n\
-          \All of these work both in channels and in private messages to me."
-
-cmdHelp' = Command
-    { cmdNames   = ["help", "Help", "h", "?"]
-    , cmdRespond = respondHelp
-    , cmdHelp    =
-        "‘help [<command> | <setting>]’ - display help for the given \
-        \command or settings option/section. Also see ‘info’.\n\
-        \FunBot intends to provide interactive help, but some topics \
-        \may be missing. If that's the case, check out the user \
-        \manual (call ‘!info links’ for the URL) or ask in #freepost."
-    }
-
--------------------------------------------------------------------------------
--- Info command
--- Ask the bot to display some information
--------------------------------------------------------------------------------
-
-topics =
-    [ ( "intro"
-      , "I’m fpbot. An instance of funbot, written in Haskell. I run in \
-        \#freepost (and some extra channels). Developed in the Freepost \
-        \community, I exist for fun, collaboration and learning. But I also \
-        \aim to provide useful tools, in particular to Freepost and related \
-        \projects and communities.\n\
-        \You can start by trying ‘!help’."
-      )
-    , ( "features"
-      , "This is a high-level list of features and subsystems I provide. It \
-        \will hopefully be kept up-to-date by updating it every time new \
-        \features are added.\n\
-        \• Help and information system (!help, !info)\n\
-        \• A settings system (!get, !set, etc.)\n\
-        \• Announcing commits, tags, merge requests, etc. in Git \
-          \repositories\n\
-        \• Announcing RSS/Atom feed items\n\
-        \• Leaving memos (requires enabling nick tracking for the channel)\n\
-        \• Announcing titles of URLs\n\
-        \• Expanding shortcuts (e.g. easily get a bug URL by its ID)\n\
-        \• Logging and reporting channel activity\n\
-        \• Welcoming new users\n\
-        \• Accepting events via an HTTP API, e.g. pastes added to a paste \
-          \bin\n\
-        \There is also an overview of the bot API features, useful to \
-        \contributors/developers, in the guide at \
-        \<https://notabug.org/fr33domlover/funbot/src/master/INSTALL.md>."
-      )
-    , ( "contrib"
-      , "Thinking about contributing to my development? Opening a ticket, \
-        \fixing a bug, implementing a feature? Check out the project page at \
-        \<https://notabug.org/fr33domlover/funbot>, which links to the \
-        \contribution guide, to the issues page and more."
-      )
-    , ( "copying"
-      , "♡ Copying is an act of love. Please copy, reuse and share me! Grab a \
-        \copy of me from <https://notabug.org/fr33domlover/funbot>."
-      )
-    , ( "links"
-      , "Website:     https://notabug.org/fr33domlover/funbot\n\
-        \Issues:      https://notabug.org/fr33domlover/funbot/issues\n\
-        \Dev guide:   https://notabug.org/fr33domlover/funbot/src/master/INSTALL.md\n\
-        \User manual: http://rel4tion.org/projects/funbot/manual"
-      )
-    , ( "commands"
-      , "A bot command is a specially formatted message sent either into an \
-        \IRC channel, or privately to me. Such a message specifies at least \
-        \the command name and a possibly empty list of whitespace-separated \
-        \parameters. Commands may have more than one name, e.g. the ‘help’ \
-        \command also has an alias ‘h’, among others. To see the details \
-        \about which parameters a specific command takes, which name aliases \
-        \it has and what it does, use ‘!help <command>’.\n\
-        \A command can be sent to me using a message starting with a command \
-        \prefix, e.g. ‘!echo hello’, or by starting a message with my \
-        \nickname, e.g. ‘funbot: echo hello’. Some commands work only when \
-        \used in a channel, some work only privately, some work in both \
-        \cases.\n\
-        \Use the ‘!info’ command to explore various aspects and features I \
-        \provide. Both !help and !info can be used privately.\n\
-        \Available commands: " ++
-        unwords (map (head . cmdNames) $ csetCommands commandSet)
-      )
-    , ( "git-ann"
-      , "I can announce development related events, such as git commits and \
-        \merge requests. To see the list of repos being announced and their \
-        \settings, run ‘!get repos’. Each repo has a list of specifications, \
-        \one per target channel (most projects announce to a single channel, \
-        \some announce to two). You can modify the spec details using the \
-        \settings system (!get, !set, etc.). To add a new spec (channel) to \
-        \an existing repo, use the !add-spec command. To remove a spec, use \
-        \!delete-spec. To add and remove repos, use !add-repo and \
-        \!delete-repo respectively."
-      )
-    , ( "feeds"
-      , "I can periodically visit news feeds (Atom, RSS, etc.) and report new \
-        \items to IRC channels. You can add and remove feeds using !add-feed \
-        \and !delete-feed respectively. You can use the settings system \
-        \(!get, !set, etc.) to control feed item display: activate and \
-        \deactivate feeds, change feed URLs, choose IRC channels in which new \
-        \items of a given feed are reported and control which information is \
-        \shown about new feed items reported."
-      )
-    , ( "memos"
-      , "You can leave a message for an offline user, and I will send them \
-        \the message when they come back. I can send them the message \
-        \privately or in the same channel you sent it to me. See the !tell \
-        \and !ctell commands. If you leave a memo for an online user, I will \
-        \send it to them instantly instead of storing it for later - but for \
-        \that to work, channel user list tracking needs to be enabled in my \
-        \settings for the relevant channel(s)."
-      )
-    , ( "channels"
-      , "Using bot commands, you can ask me to leave and join channels. The \
-        \list of channels I'm present in can therefore be controlled \
-        \dynamically. However, there is also an additional list of channels \
-        \in my configuration (in the source). Using !visit, you can ask me to \
-        \briefly join a channel. But I won't remember it next time. To make \
-        \me a permanent member, use !join. You can ask me to leave a channel \
-        \using !leave."
-      )
-    , ( "chan-history"
-      , "When you join a channel, I can send you the last messages sent there \
-        \so that you’ll know what was happening before you came. You can \
-        \enable this per channel, and set the number of last messages you’d \
-        \like to see per channel. Note that the number of messages you’ll get \
-        \also depends on how many messages I myself remember for this \
-        \purpose (which is set in my data files). See ‘!info user-options’ \
-        \for usage instructions. If you'd like to see channel history once, \
-        \without enabling the automatic service, see ‘!help show-history’."
-      )
-    , ( "user-options"
-      , "I keep private per-user options which affect our interaction. These \
-        \options are /separate/ from the public settings system. The commands \
-        \for managing them are available only in private messages to me, and \
-        \don't work in IRC channels. You can view your preferences using \
-        \!show-opts. Edit them using !enable-history, !disable-history and \
-        \!set-history-lines. Reset them to defaults using !erase-opts."
-      )
-    , ( "quotes"
-      , "See the !quote command. It works, but quotes aren’t being \
-        \automatically published, so perhaps it isn’t very useful at the \
-        \moment. Perhaps unless you setup the publishing yourself."
-      )
-    , ( "shortcuts"
-      , "I can detect special strings in your messages and send longer forms \
-        \of them, allowing you to define shortcuts. For example, you can \
-        \mention a bug ID and I will send a bug tracker URL for that bug. I \
-        \detect shortcuts by their prefix: Once you define a <prefix> string, \
-        \I find occurrences of <prefix><word> in channel messages, and resend \
-        \<word> into the channel, with <before> and <after> strings \
-        \surrounding it, thus expanding the shortcut. For example, if \
-        \<prefix>=BUG <before>=http://bug.org/ <after>=.html, then if you \
-        \send a message containing BUG142, I will send \
-        \http://bug.org/142.html into the channel. See the !add-shortcut and \
-        \!delete-shortcut commands, and relevant settings."
-      )
-    ]
-
-respondInfo _mchan _nick [] send =
-    send $ "Topics: " ++ intercalate ", " (map fst topics)
-respondInfo mchan nick [arg] send =
-    case lookup arg topics of
-        Just msg -> send msg
-        Nothing ->  failBack mchan nick $ InvalidArg (Just 1) (Just arg)
-respondInfo mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) Nothing
-
-cmdInfo = Command
-    { cmdNames   = ["info", "i"]
-    , cmdRespond = respondInfo
-    , cmdHelp    = "‘info’         - list topics. Also see ‘help’.\n\
-                   \‘info <topic>’ - display topic information."
-    }
-
--------------------------------------------------------------------------------
--- Tell command
--- Tell something to some other user
--------------------------------------------------------------------------------
-
--- Given whether to always send privately, return a command response
-respondTell priv mchan sender (recip:msghead:msgtail) _send =
-    submitMemo sender mchan recip priv (unwords $ msghead : msgtail)
-respondTell _priv mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) Nothing
-
-cmdPTell = Command
-    { cmdNames   = ["tell", "ptell"]
-    , cmdRespond = respondTell True
-    , cmdHelp    =
-        "‘tell <nick> <text>’ - leave a memo for a user to see later. \
-        \Memos can be sent to the recipient privately, or publicly \
-        \in the channel in which they were submitted. With this \
-        \command, the memo will be sent privately. If that isn't your \
-        \intention, see the ctell command."
-    }
-
-cmdCTell = Command
-    { cmdNames   = ["ctell"]
-    , cmdRespond = respondTell False
-    , cmdHelp    =
-        "‘tell <nick> <text>’ - leave a memo for a user to see later. \
-        \Memos can be sent to the recipient privately, or publicly \
-        \in the channel in which they were submitted. With this \
-        \command, the memo will be sent in the same way it was \
-        \submitted: If you submit it in a channel, it will be sent to \
-        \the recipient in the same channel. If you submit using a \
-        \private message to me, I will also send it privately to the \
-        \recipient.\n\
-        \If that isn't your intention, see the tell command."
-    }
-
--------------------------------------------------------------------------------
--- Get, set, enable and disable commands
--- Manage bot settings
--------------------------------------------------------------------------------
-
-respondGet _mchan _nick []     send  = respondGet' "" send
-respondGet _mchan _nick [path] send  = respondGet' path send
-respondGet mchan  nick  args   _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
-
-respondSet _mchan _nick [name, val] send  = respondSet' name val send
-respondSet mchan  nick  args        _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 2)
-
-respondReset _mchan _nick [name] send  = respondReset' name send
-respondReset mchan  nick  args   _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
-
--- Given a boolean, create an enable/disable response accordingly
-respondBool val  _mchan _nick [name] send  =
-    respondSet' name (showOption val) send
-respondBool _val mchan  nick  args   _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
-
-respondEnable = respondBool True
-
-respondDisable = respondBool False
-
-cmdGet = Command
-    { cmdNames   = ["get"]
-    , cmdRespond = respondGet
-    , cmdHelp    = "‘get <option>’ - get the value of a settings option at the \
-                \given path"
-    }
-
-cmdSet = Command
-    { cmdNames   = ["set"]
-    , cmdRespond = respondSet
-    , cmdHelp    = "‘set <option> <value>’ - set a settings option at the \
-                   \given path to the given value"
-    }
-
-cmdReset = Command
-    { cmdNames   = ["reset"]
-    , cmdRespond = respondReset
-    , cmdHelp    = "‘reset <option>’ - set a settings option at the given \
-                   \path to its default value"
-    }
-
-cmdEnable = Command
-    { cmdNames   = ["enable"]
-    , cmdRespond = respondEnable
-    , cmdHelp    = "‘enable <option>’ - set a settings option at the given \
-                   \path to True"
-    }
-
-cmdDisable = Command
-    { cmdNames   = ["disable"]
-    , cmdRespond = respondDisable
-    , cmdHelp    = "‘disable <option>’ - set a settings option at the given \
-                   \path to False"
-    }
-
--------------------------------------------------------------------------------
--- Add-spec, delete-spec, add-repo, delete-repo commands
--- Manage git repo event announcement details
--------------------------------------------------------------------------------
-
-respondAddSpec mchan nick [repo, owner, chan] send = do
-    succ <- addPushAnnSpec repo owner chan
-    if succ
-        then send "Git event announcement spec added."
-        else failBack mchan nick $ OtherFail "No such repo/owner pair found."
-respondAddSpec mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 3)
-
-cmdAddSpec = Command
-    { cmdNames   = ["add-spec"]
-    , cmdRespond = respondAddSpec
-    , cmdHelp    = "‘add-spec <repo> <owner> <channel>’ - add a channel to \
-                   \which git repo events will be announced, with default \
-                   \settings"
-    }
-
-respondDeleteSpec mchan nick [repo, owner, num] send =
-    case readMaybe num of
-        Nothing  -> failBack mchan nick $ InvalidArg (Just 3) (Just num)
-        Just pos ->
-            if pos < 1
-                then failBack mchan nick $ InvalidArg (Just 3) (Just num)
-                else do
-                    res <- deletePushAnnSpec repo owner (pos - 1)
-                    case res of
-                        Nothing    -> send $ "Git event announcement spec "
-                                          ++ num
-                                          ++ " removed."
-                        Just False -> failBack mchan nick $ OtherFail
-                                      "No such repo/owner pair found."
-                        Just True  -> failBack mchan nick $ OtherFail
-                                      "Spec number out of range."
-respondDeleteSpec mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 3)
-
-cmdDeleteSpec = Command
-    { cmdNames   = ["delete-spec"]
-    , cmdRespond = respondDeleteSpec
-    , cmdHelp    = "‘delete-spec <repo> <owner> <num>’ - remove a git event \
-                   \announcement specification with the given index number \
-                   \(as found in the settings tree, i.e. starting from 1) \
-                   \from the given repo."
-    }
-
-invalid s = null s || '/' `elem` s
-
-respondAddRepo mchan nick [repo, owner, chan] send
-    | invalid repo =
-        failBack mchan nick $ InvalidArg (Just 1) (Just repo)
-    | invalid owner =
-        failBack mchan nick $ InvalidArg (Just 2) (Just owner)
-    | otherwise = do
-        succ <- addRepo repo owner chan
-        if succ
-            then send "Git repo added."
-            else failBack mchan nick $ OtherFail
-                 "This repo is already registered."
-respondAddRepo mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 3)
-
-cmdAddRepo = Command
-    { cmdNames   = ["add-repo"]
-    , cmdRespond = respondAddRepo
-    , cmdHelp    = "‘add-repo <repo> <owner> <channel>’ - add a repo to \
-                   \announce its events in the given channel, with default \
-                   \settings."
-    }
-
-respondDeleteRepo mchan nick [repo, owner] send = do
-    succ <- deleteRepo repo owner
-    if succ
-        then send "Git repo removed."
-        else failBack mchan nick $ OtherFail "No such repo/owner pair found."
-respondDeleteRepo mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 2)
-
-cmdDeleteRepo = Command
-    { cmdNames   = ["delete-repo"]
-    , cmdRespond = respondDeleteRepo
-    , cmdHelp    = "‘delete-repo <repo> <owner>’ - stop announcing events for \
-                   \the given repo and remove its settings."
-    }
-
--------------------------------------------------------------------------------
--- Visit, join, leave commands
--- Manage bot channels
--------------------------------------------------------------------------------
-
-respondVisit _mchan _nick [chan] send = do
-    memb <- botMemberOf chan
-    send $ if memb
-        then "I'm already a member of that channel. I think. Trying anyway."
-        else "Visiting " ++ chan
-    joinChannel chan Nothing
-respondVisit mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
-
-cmdVisit = Command
-    { cmdNames   = ["visit"]
-    , cmdRespond = respondVisit
-    , cmdHelp    =
-        "‘visit <channel>’ - ask the bot to join a given channel, \
-        \without storing state. So the bot won't remember to join it \
-        \next time and default settings will be used. This is useful \
-        \for short tests perhaps. If you'd like the bot to become a \
-        \permanent member, use !join."
-    }
-
-respondJoin _mchan _nick [chan] send = do
-    memb <- botMemberOf chan
-    sel <- channelSelected chan
-    send $ if memb && sel
-        then "I'm already a member of that channel. I think. Trying anyway."
-        else "Becoming a member of " ++ chan
-    joinChannel chan Nothing
-    addChannel chan
-respondJoin mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
-
-cmdJoin = Command
-    { cmdNames   = ["join"]
-    , cmdRespond = respondJoin
-    , cmdHelp    =
-        "‘join <channel>’ - ask the bot to join a given channel, and \
-        \make them a permanent member, automatically joining on bot \
-        \restart. You can use !leave to make the bot leave. To invite \
-        \the bot just for a temporary session, use !visit."
-    }
-
-respondLeave Nothing _nick [] send = send "This works only in a channel."
-respondLeave (Just chan) nick [] send = do
-    unselectChannel chan
-    send $ "Leaving the channel for now. Bye! o/"
-    partChannel chan $ Just $ "Asked by " ++ nick ++ " to leave"
-respondLeave mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 0)
-
-cmdLeave = Command
-    { cmdNames   = ["leave"]
-    , cmdRespond = respondLeave
-    , cmdHelp    =
-        "‘leave’ - ask the bot to leave the current channel, i.e. the \
-        \one in which this command has been used. If the channel was \
-        \selected for auto-joining, it is now unselected."
-    }
-
--------------------------------------------------------------------------------
--- Quote command
--- Record something someone said
--------------------------------------------------------------------------------
-
-respondQuote (Just chan) _nick [target] _send = quote chan target
-respondQuote Nothing     _nick [_]      _send = return ()
-respondQuote mchan       nick  args     _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
-
-cmdQuote = Command
-    { cmdNames   = ["quote"]
-    , cmdRespond = respondQuote
-    , cmdHelp    = "‘quote <nick>’ - record the last message sent by <nick> \
-                   \in the channel. The list of past quotes is publicly \
-                   \available."
-    }
-
--------------------------------------------------------------------------------
--- Show-opts, enable-history, disable-history, set-history-lines,
--- get-history-lines, erase-opts commands
--- Manage user options
--------------------------------------------------------------------------------
-
-priv = "That command works only in private conversation with me."
-
-looksLikeChan []     = False
-looksLikeChan (c:cs) = c `elem` "#+!&"
-
-notchan chan = chan ++ " doesn't look like a channel name."
-
-respondShowOpts (Just _chan) _nick _args  send  = send priv
-respondShowOpts Nothing      nick  []     _send = sendChannels nick
-respondShowOpts Nothing      nick  [chan] send  =
-    if looksLikeChan chan
-        then sendHistoryOpts nick chan
-        else send $ notchan chan
-respondShowOpts Nothing      nick  args   _send =
-    failToUser nick $ WrongNumArgsN (Just $ length args) Nothing
-
-cmdShowOpts = Command
-    { cmdNames   = ["show-opts", "show-options"]
-    , cmdRespond = respondShowOpts
-    , cmdHelp    =
-        "‘show-opts’           - list channels for which you set history \
-        \display options.\n\
-        \‘show-opts <channel>’ - show history display options for the given \
-        \channel."
-    }
-
-respondHistory _enable (Just _chan) _nick _args  send  = send priv
-respondHistory enable  Nothing      nick  [chan] send  =
-    if looksLikeChan chan
-        then do
-            setEnabled nick chan enable
-            hls <- getHistoryLines chan
-            when (enable && hls < 1) $ send
-                "Note that while you enabled personal history display, \
-                \history logging is disabled for that channel, therefore you \
-                \won't be getting history messages. Ask the bot maintainer(s) \
-                \to enable it."
-        else send $ notchan chan
-respondHistory _enable Nothing      nick  args   _send =
-    failToUser nick $ WrongNumArgsN (Just $ length args) (Just 1)
-
-cmdEnableHistory = Command
-    { cmdNames   = ["enable-history"]
-    , cmdRespond = respondHistory True
-    , cmdHelp    = "‘enable-history <channel>’ - enable automatic history \
-                   \private display for the given channel."
-    }
-
-cmdDisableHistory = Command
-    { cmdNames   = ["disable-history"]
-    , cmdRespond = respondHistory False
-    , cmdHelp    = "‘disable-history <channel>’ - disable automatic history \
-                   \private display for the given channel."
-    }
-
-respondSetLines (Just _chan) _nick _args       send  = send priv
-respondSetLines Nothing      nick  [chan, len] send  =
-    if looksLikeChan chan
-        then
-            case readMaybe len of
-                Nothing -> badLen
-                Just n  ->
-                    if n < 0
-                        then badLen
-                        else setMaxLines nick chan n
-        else send $ notchan chan
-    where
-    badLen = failToUser nick $ InvalidArg (Just 2) (Just len)
-respondSetLines Nothing      nick  args        _send =
-    failToUser nick $ WrongNumArgsN (Just $ length args) (Just 2)
-
-cmdSetLines = Command
-    { cmdNames   = ["set-history-lines"]
-    , cmdRespond = respondSetLines
-    , cmdHelp    = "‘set-history-lines <channel> <num>’ - set the maximal \
-                   \number of channel history lines to display."
-    }
-
-respondEraseOpts (Just _chan) _nick _args send  = send priv
-respondEraseOpts Nothing      nick  []    _send = eraseOpts nick
-respondEraseOpts Nothing      nick  args  _send =
-    failToUser nick $ WrongNumArgsN (Just $ length args) (Just 0)
-
-cmdEraseOpts = Command
-    { cmdNames   = ["erase-opts", "erase-options"]
-    , cmdRespond = respondEraseOpts
-    , cmdHelp    = "‘erase-opts’ - reset all your options back to defaults."
-    }
-
--------------------------------------------------------------------------------
--- Add-shortcut, delete-shortcut commands
--- Manage shortcuts
--------------------------------------------------------------------------------
-
-respondAddShortcut mchan nick [label, chan] send =
-    if looksLikeChan chan
-        then do
-            succ <- addShortcut label chan
-            if succ
-                then send "Shortcut added."
-                else failBack mchan nick $ OtherFail
-                     "This shortcut is already registered."
-        else send $ notchan chan
-respondAddShortcut mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 2)
-
-cmdAddShortcut = Command
-    { cmdNames   = ["add-shortcut"]
-    , cmdRespond = respondAddShortcut
-    , cmdHelp    = "‘add-shortcut <label> <channel>’ - add a shortcut to \
-                   \apply in the given channel, with default dummy settings. \
-                   \The label is just for convenient reference; pick a short \
-                   \meaningful one."
-    }
-
-respondDeleteShortcut mchan nick [label] send = do
-    succ <- deleteShortcut label
-    if succ
-        then send "Shortcut removed."
-        else failBack mchan nick $ OtherFail "No such shortcut found."
-respondDeleteShortcut mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
-
-cmdDeleteShortcut = Command
-    { cmdNames   = ["delete-shortcut"]
-    , cmdRespond = respondDeleteShortcut
-    , cmdHelp    = "‘delete-shortcut <label>’ - remove the shortcut with the \
-                   \given label completely. If you just want to change the \
-                   \channels in which it applies, see the \
-                   \“shortcuts.<label>.channels” settings option."
-    }
-
--------------------------------------------------------------------------------
--- Show-history command
--- Show channel history
--------------------------------------------------------------------------------
-
-respondShowHistory (Just chan) nick [mins] _send =
-    case readMaybe mins of
-        Nothing -> badMins
-        Just m  ->
-            if m < 0
-                then badMins
-                else reportHistory' nick chan m
-    where
-    badMins = failToChannel chan nick $ InvalidArg (Just 1) (Just mins)
-respondShowHistory mchan nick [mins, chan] _send =
-    case readMaybe mins of
-        Nothing -> badMins
-        Just m  ->
-            if m < 0
-            then badMins
-            else reportHistory' nick chan m
-    where
-    badMins = failBack mchan nick $ InvalidArg (Just 1) (Just mins)
-respondShowHistory mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) Nothing
-
-cmdShowHistory = Command
-    { cmdNames   = ["show-history"]
-    , cmdRespond = respondShowHistory
-    , cmdHelp    =
-        "‘show-history <mins>’           - Receive (privately) the channel \
-        \history log for the last <mins> minutes, for the channel in which \
-        \the command is used. The result may contain less than <mins> minutes \
-        \because it also depends on the number of messages the bot \
-        \remembers.\n\
-        \‘show-history <mins> <channel>’ - The same, for the specified \
-        \channel. Use this variant in a private message to the bot."
-    }
-
--------------------------------------------------------------------------------
--- Add-feed and delete-feed commands
--- Add and remove news feeds to watch
--------------------------------------------------------------------------------
-
-respondAddFeed mchan nick [label, url] send = do
-    succ <- addFeed label url
-    if succ
-        then send "News feed added."
-        else failBack mchan nick $ OtherFail "This label already exists."
-respondAddFeed mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 2)
-
-cmdAddFeed = Command
-    { cmdNames   = ["add-feed"]
-    , cmdRespond = respondAddFeed
-    , cmdHelp    =
-        "‘add-feed <label> <url>’ - add a news feed to watch and announce its \
-        \new items to IRC channels. By default the list of IRC channels is \
-        \empty; use the settings system (!set) to set one or more channels to \
-        \which items will be announced."
-    }
-
-respondDeleteFeed mchan nick [label] send = do
-    succ <- deleteFeed label
-    if succ
-        then send "News feed removed."
-        else failBack mchan nick $ OtherFail "No such label found."
-respondDeleteFeed mchan nick args _send =
-    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
-
-cmdDeleteFeed = Command
-    { cmdNames   = ["delete-feed"]
-    , cmdRespond = respondDeleteFeed
-    , cmdHelp    =
-        "‘delete-feed <label>’ - stop announcing items of the given news feed \
-        \and remove its settings. If you just want to disable the feed, \
-        \without deleting the settings, use the settings system to deactivate \
-        \it."
     }
diff --git a/src/FunBot/Commands/Channels.hs b/src/FunBot/Commands/Channels.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/Channels.hs
@@ -0,0 +1,145 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Visit, join, leave commands
+--
+-- Manage bot channels
+module FunBot.Commands.Channels
+    ( cmdVisit
+    , cmdJoin
+    , cmdLeave
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import FunBot.History (quote, reportHistory')
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels (addChannel)
+import FunBot.Settings.Sections.Feeds (addFeed, deleteFeed)
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts (addShortcut, deleteShortcut)
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util (getHistoryLines, cmds, helps)
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Text.Read (readMaybe)
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+
+respondVisit
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondVisit _mchan _nick [chan] send = do
+    memb <- botMemberOf $ Channel chan
+    send $ MsgContent $ if memb
+        then "I’m already a member of that channel. I think. Trying anyway."
+        else "Visiting " <> chan
+    joinChannel (Channel chan) Nothing
+respondVisit mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+cmdVisit = Command
+    { cmdNames    = cmds ["visit"]
+    , cmdRespond  = respondVisit
+    , cmdHelp     = helps
+        [ ( "visit <channel>"
+          , "ask the bot to join a given channel, without storing state. So \
+            \the bot won’t remember to join it next time and default settings \
+            \will be used. This is useful for short tests perhaps. If you’d \
+            \like the bot to become a permanent member, use !join."
+          )
+        ]
+    , cmdExamples =
+        [ "visit #freepost"
+        ]
+    }
+
+respondJoin
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondJoin _mchan _nick [chan] send = do
+    memb <- botMemberOf $ Channel chan
+    sel <- channelSelected $ Channel chan
+    send $ MsgContent $ if memb && sel
+        then "I'm already a member of that channel. I think. Trying anyway."
+        else "Becoming a member of " <> chan
+    joinChannel (Channel chan) Nothing
+    addChannel $ Channel chan
+respondJoin mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+cmdJoin = Command
+    { cmdNames    = cmds ["join"]
+    , cmdRespond  = respondJoin
+    , cmdHelp     = helps
+        [ ( "join <channel>"
+          , "ask the bot to join a given channel, and make them a permanent \
+            \member, automatically joining on bot restart. You can use !leave \
+            \to make the bot leave. To invite the bot just for a temporary \
+            \session, use !visit."
+          )
+        ]
+    , cmdExamples =
+        [ "join #freepost"
+        ]
+    }
+
+respondLeave
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondLeave Nothing _nick [] send = send $ MsgContent "This works only in a channel."
+respondLeave (Just chan) nick [] send = do
+    unselectChannel chan
+    send $ MsgContent $ "Leaving the channel for now. Bye! o/"
+    partChannel chan $ Just $ Comment $ "Asked by " <> unNickname nick <> " to leave"
+respondLeave mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 0)
+
+cmdLeave = Command
+    { cmdNames    = cmds ["leave"]
+    , cmdRespond  = respondLeave
+    , cmdHelp     = helps
+        [ ( "leave"
+          , "ask me to leave the current channel, i.e. the one in which this \
+            \command has been used. If the channel was selected for \
+            \auto-joining, it is now unselected."
+          )
+        ]
+    , cmdExamples =
+        [ "leave #freepost"
+        ]
+    }
diff --git a/src/FunBot/Commands/Feeds.hs b/src/FunBot/Commands/Feeds.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/Feeds.hs
@@ -0,0 +1,110 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Add-feed and delete-feed commands
+--
+-- Add and remove news feeds to watch
+module FunBot.Commands.Feeds
+    ( cmdAddFeed
+    , cmdDeleteFeed
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import FunBot.History (quote, reportHistory')
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels (addChannel)
+import FunBot.Settings.Sections.Feeds (addFeed, deleteFeed)
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts (addShortcut, deleteShortcut)
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util (getHistoryLines, cmds, helps)
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Text.Read (readMaybe)
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+
+respondAddFeed
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondAddFeed mchan nick [label, url] send = do
+    succ <- addFeed (FeedLabel $ CI.mk label) url
+    if succ
+        then send $ MsgContent "News feed added."
+        else failBack mchan nick $ OtherFail "This label already exists."
+respondAddFeed mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 2)
+
+cmdAddFeed = Command
+    { cmdNames    = cmds ["feed+", "add-feed"]
+    , cmdRespond  = respondAddFeed
+    , cmdHelp     = helps
+        [ ( "feed+ <label> <url>"
+          , "add a news feed to watch and announce its new items to IRC \
+            \channels. By default the list of IRC channels is empty; use the \
+            \settings system (!set) to set one or more channels to which \
+            \items will be announced."
+          )
+        ]
+    , cmdExamples =
+        [ "feed+ freepost http://freepo.st/rss/freepost"
+        ]
+    }
+
+respondDeleteFeed
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondDeleteFeed mchan nick [label] send = do
+    succ <- deleteFeed $ FeedLabel $ CI.mk label
+    if succ
+        then send $ MsgContent "News feed removed."
+        else failBack mchan nick $ OtherFail "No such label found."
+respondDeleteFeed mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+cmdDeleteFeed = Command
+    { cmdNames    = cmds ["feed-", "delete-feed"]
+    , cmdRespond  = respondDeleteFeed
+    , cmdHelp     = helps
+        [ ( "feed- <label>"
+          , "stop announcing items of the given news feed and remove its \
+            \settings. If you just want to disable the feed, without deleting \
+            \the settings, use the settings system to deactivate it."
+          )
+        ]
+    , cmdExamples =
+        [ "feed- freepost"
+        ]
+    }
diff --git a/src/FunBot/Commands/History.hs b/src/FunBot/Commands/History.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/History.hs
@@ -0,0 +1,88 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Show-history command
+--
+-- Show channel history
+module FunBot.Commands.History
+    ( cmdShowHistory
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import FunBot.History (quote, reportHistory')
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels (addChannel)
+import FunBot.Settings.Sections.Feeds (addFeed, deleteFeed)
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts (addShortcut, deleteShortcut)
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util (getHistoryLines, cmds, helps)
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Text.Read (readMaybe)
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+
+respondShowHistory
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondShowHistory (Just chan) nick [mins] _send =
+    case TR.decimal mins of
+        Right (m, "") -> reportHistory' nick chan m
+        _ -> failToChannel chan nick $ InvalidArg (Just 1) (Just mins)
+respondShowHistory mchan nick [mins, chan] _send =
+    case TR.decimal mins of
+        Right (m, "") -> reportHistory' nick (Channel chan) m
+        _ -> failBack mchan nick $ InvalidArg (Just 1) (Just mins)
+respondShowHistory mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) Nothing
+
+cmdShowHistory = Command
+    { cmdNames    = cmds ["show-history"]
+    , cmdRespond  = respondShowHistory
+    , cmdHelp     = helps
+        [ ( "show-history <mins>"
+          , "receive (privately) the channel history log for the last <mins> \
+            \minutes, for the channel in which the command is used. The \
+            \result may contain less than <mins> minutes because it also \
+            \depends on the number of messages the bot remembers."
+          )
+        , ( "show-history <mins> <channel>"
+          , "the same, for the specified channel. Use this variant in a \
+            \private message to the bot."
+          )
+        ]
+    , cmdExamples =
+        [ "show-history 5"
+        , "show-history 5 #freepost"
+        ]
+    }
diff --git a/src/FunBot/Commands/Info.hs b/src/FunBot/Commands/Info.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/Info.hs
@@ -0,0 +1,306 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Help and info commands
+--
+-- Show command help, settings help, info topics
+module FunBot.Commands.Info
+    ( cmdHelp'
+    , cmdInfo
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import FunBot.Config (introInfo)
+import FunBot.History (quote, reportHistory')
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels (addChannel)
+import FunBot.Settings.Sections.Feeds (addFeed, deleteFeed)
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts (addShortcut, deleteShortcut)
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Network.IRC.Fun.Client.IO (connNickname)
+import Text.Read (readMaybe)
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+
+respondHelp
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondHelp _mchan nick [cname] send = do
+    cset <- askBehaviorS $ head . commandSets
+    let prefix = csetPrefix cset
+        cname' = CommandName $ CI.mk $ case T.uncons cname of
+            Nothing     -> cname
+            Just (c, r) -> if c == prefix then r else cname
+    case find ((cname' `elem`) . cmdNames) $ csetCommands cset of
+        Just cmd -> do
+            bnick <- askConfigS $ unNickname . connNickname . cfgConnection
+            let refs =
+                    [ T.singleton prefix
+                    , bnick <> ": "
+                    , bnick <> ", "
+                    ]
+                refsC = cycle refs
+                pseudoRandom = T.length (unNickname nick) `mod` length refs
+                refsCR = drop pseudoRandom refsC
+                exline p t = "\nExample: " <> p <> t
+                examples = map (uncurry exline) $ zip refsCR (cmdExamples cmd)
+                cmdnames = map (CI.original . unCommandName) $ cmdNames cmd
+            send $ MsgContent $ T.concat $
+                  cmdHelp cmd
+                : "\nCommand names: "
+                : T.intercalate ", " cmdnames
+                : examples
+        Nothing  -> do
+            succ <- respondSettingsHelp cname send
+            unless succ $ send $ MsgContent
+                "No such command, or invalid settings path. \
+                \Maybe try just ‘!help’ without a parameter."
+respondHelp _mchan nick _params send =
+     send "!info intro    - about me\n\
+          \!info commands - how to ask me to do things\n\
+          \!info          - list of help/information topics\n\
+          \!help help     - how to use this command\n\
+          \All of these work both in channels and in private messages to me."
+cmdHelp' = Command
+    { cmdNames    = cmds ["help", "h", "?"]
+    , cmdRespond  = respondHelp
+    , cmdHelp     = helps
+        [ ( "help [<command> | <setting>]"
+          , "display help for the given command or settings option/section. \
+            \Also see ‘info’.\n\
+            \FunBot intends to provide interactive help, but some topics \
+            \may be missing. If that’s the case, check out the user manual \
+            \(call ‘!info links’ for the URL) or ask in #freepost."
+          )
+        ]
+    , cmdExamples =
+        [ "help echo"
+        , "help channels.#freepost.welcome"
+        ]
+    }
+
+topics =
+    [ ( "intro"
+      , introInfo <> "\nYou can start by trying ‘!help’."
+      )
+    , ( "features"
+      , "This is a high-level list of features and subsystems I provide. It \
+        \will hopefully be kept up-to-date by updating it every time new \
+        \features are added.\n\
+        \• Help and information system (!help, !info)\n\
+        \• A settings system (!get, !set, etc.)\n\
+        \• Announcing commits, tags, issues, merge requests, etc. in Git \
+          \repositories\n\
+        \• Announcing RSS/Atom feed items\n\
+        \• Leaving memos (requires enabling nick tracking for the channel)\n\
+        \• Announcing titles of URLs\n\
+        \• Expanding shortcuts (e.g. easily get a bug URL by its ID)\n\
+        \• Logging and reporting channel activity\n\
+        \• Welcoming new users\n\
+        \• Accepting events via an HTTP API, e.g. pastes added to a paste \
+          \bin\n\
+        \There is also an overview of the bot API features, useful to \
+        \contributors/developers, in the guide at \
+        \<https://notabug.org/fr33domlover/funbot/src/master/INSTALL.md>."
+      )
+    , ( "contrib"
+      , "Thinking about contributing to my development? Opening a ticket, \
+        \fixing a bug, implementing a feature? Check out the project page at \
+        \<https://notabug.org/fr33domlover/funbot>, which links to the \
+        \contribution guide, to the issues page and more."
+      )
+    , ( "copying"
+      , "♡ Copying is an act of love. Please copy, reuse and share me! Grab a \
+        \copy of me from <https://notabug.org/fr33domlover/funbot>."
+      )
+    , ( "links"
+      , "Website:     https://notabug.org/fr33domlover/funbot\n\
+        \Issues:      https://notabug.org/fr33domlover/funbot/issues\n\
+        \Dev guide:   https://notabug.org/fr33domlover/funbot/src/master/INSTALL.md\n\
+        \User manual: http://rel4tion.org/projects/funbot/manual"
+      )
+    , ( "commands"
+      , "A bot command is a specially formatted message sent either into an \
+        \IRC channel, or privately to me. Such a message specifies at least \
+        \the command name and a possibly empty list of whitespace-separated \
+        \parameters. Commands may have more than one name, e.g. the ‘help’ \
+        \command also has an alias ‘h’, among others. To see the details \
+        \about which parameters a specific command takes, which name aliases \
+        \it has and what it does, use ‘!help <command>’.\n\
+        \A command can be sent to me using a message starting with a command \
+        \prefix, e.g. ‘!echo hello’, or by starting a message with my \
+        \nickname, e.g. ‘funbot: echo hello’. Some commands work only when \
+        \used in a channel, some work only privately, some work in both \
+        \cases.\n\
+        \Use the ‘!info’ command to explore various aspects and features I \
+        \provide. Both !help and !info can be used privately."-- \n\
+        -- \Available commands: " <>
+        -- T.unwords (map (CI.original . unCommandName . head . cmdNames) $ csetCommands commandSet)
+      )
+    , ( "git-ann"
+      , "I can announce development related events, such as git commits and \
+        \merge requests. To see the list of repos being announced and their \
+        \settings, run ‘!get repos’. Each repo has a list of specifications, \
+        \one per target channel (most projects announce to a single channel, \
+        \some announce to two). You can modify the spec details using the \
+        \settings system (!get, !set, etc.). To add a new spec (channel) to \
+        \an existing repo, use the !add-spec command. To remove a spec, use \
+        \!delete-spec. To add and remove repos, use !add-repo and \
+        \!delete-repo respectively."
+      )
+    , ( "feeds"
+      , "I can periodically visit news feeds (Atom, RSS, etc.) and report new \
+        \items to IRC channels. You can add and remove feeds using !add-feed \
+        \and !delete-feed respectively. You can use the settings system \
+        \(!get, !set, etc.) to control feed item display: activate and \
+        \deactivate feeds, change feed URLs, choose IRC channels in which new \
+        \items of a given feed are reported and control which information is \
+        \shown about new feed items reported."
+      )
+    , ( "memos"
+      , "You can leave a message for an offline user, and I will send them \
+        \the message when they come back. I can send them the message \
+        \privately or in the same channel you sent it to me. See the !tell \
+        \and !ctell commands. If you leave a memo for an online user, I will \
+        \send it to them instantly instead of storing it for later - but for \
+        \that to work, channel user list tracking needs to be enabled in my \
+        \settings for the relevant channel(s)."
+      )
+    , ( "channels"
+      , "Using bot commands, you can ask me to leave and join channels. The \
+        \list of channels I'm present in can therefore be controlled \
+        \dynamically. However, there is also an additional list of channels \
+        \in my configuration (in the source). Using !visit, you can ask me to \
+        \briefly join a channel. But I won't remember it next time. To make \
+        \me a permanent member, use !join. You can ask me to leave a channel \
+        \using !leave."
+      )
+    , ( "chan-history"
+      , "When you join a channel, I can send you the last messages sent there \
+        \so that you’ll know what was happening before you came. You can \
+        \enable this per channel, and set the number of last messages you’d \
+        \like to see per channel. Note that the number of messages you’ll get \
+        \also depends on how many messages I myself remember for this \
+        \purpose (which is set in my data files). See ‘!info user-options’ \
+        \for usage instructions. If you'd like to see channel history once, \
+        \without enabling the automatic service, see ‘!help show-history’."
+      )
+    , ( "user-options"
+      , "I keep private per-user options which affect our interaction. These \
+        \options are /separate/ from the public settings system. The commands \
+        \for managing them are available only in private messages to me, and \
+        \don't work in IRC channels. You can view your preferences using \
+        \!show-opts. Edit them using !enable-history, !disable-history and \
+        \!set-history-lines. Reset them to defaults using !erase-opts."
+      )
+    , ( "quotes"
+      , "See the !quote command. It works, but quotes aren’t being \
+        \automatically published, so perhaps it isn’t very useful at the \
+        \moment. Perhaps unless you setup the publishing yourself."
+      )
+    , ( "shortcuts"
+      , "I can detect special strings in your messages and send longer forms \
+        \of them, allowing you to define shortcuts. For example, you can \
+        \mention a bug ID and I will send a bug tracker URL for that bug. I \
+        \detect shortcuts by their prefix: Once you define a <prefix> string, \
+        \I find occurrences of <prefix><word> in channel messages, and resend \
+        \<word> into the channel, with <before> and <after> strings \
+        \surrounding it, thus expanding the shortcut. For example, if \
+        \<prefix>=BUG <before>=http://bug.org/ <after>=.html, then if you \
+        \send a message containing BUG142, I will send \
+        \http://bug.org/142.html into the channel. See the !add-shortcut and \
+        \!delete-shortcut commands, and relevant settings."
+      )
+    , ( "locations"
+      , "I maintain a simple key-value mapping, where both keys and values \
+        \are text. The intended usage (but certainly not the only usage \
+        \possible) is to map short labels to URLs, therefore I call this \
+        \mapping the location map, or the Where map. There is a single \
+        \“global” map and each channel has its own map too. When searching \
+        \for a given label I go to the per-channel map, and if I can’t find \
+        \the label there, then I check in the global map. The commands for \
+        \querying the location map are !where, !lwhere and !gwhere. The \
+        \commands for adding and removing locations are !lwhere+, !gwhere+, \
+        \!lwhere- and !gwhere-. It’s also possible to view and modify the map \
+        \values (the URLs) using the settings system (e.g. !get and !set)."
+      )
+    , ( "puppet"
+      , "I have a puppet system which allows IRC users to send arbitrary \
+        \messages through me. This can be useful for manually identifying me \
+        \with NickServ and probably for various hacks. But this feature may \
+        \also be dangerous, therefore it has some safety mechanisms and in \
+        \general you should use it with care. It works as follows: There are \
+        \lists of “puppeteers”, which are users who can use the puppet \
+        \system. There’s one global list for users who can use the system in \
+        \any channel, and there are per-channel lists, in which users can use \
+        \the system only in the channel where they’re listed. The users in \
+        \the global list can also use the system in private messages. Once \
+        \you’re a puppeteer, you can start puppet mode using the \
+        \!puppet-start command. Then you can use !puppet-say or !puppet-echo \
+        \to ask me to send your message. Finally, use !puppet-end to turn off \
+        \puppet mode. TODO at the time of writing there are no commands yet \
+        \for private puppet mode. There are also no commands for managing the \
+        \puppeteer lists: This requires access to my JSON state files."
+      )
+    ]
+
+respondInfo
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondInfo _mchan _nick [] send =
+    send $ MsgContent $ "Topics: " <> T.intercalate ", " (map fst topics)
+respondInfo mchan nick [arg] send =
+    case lookup arg topics of
+        Just msg -> send $ MsgContent msg
+        Nothing ->  failBack mchan nick $ InvalidArg (Just 1) (Just arg)
+respondInfo mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) Nothing
+
+cmdInfo = Command
+    { cmdNames    = cmds ["info", "i"]
+    , cmdRespond  = respondInfo
+    , cmdHelp     = helps
+        [ ("info"         , "list topics. Also see ‘help’.")
+        , ("info <topic>" , "display topic information.")
+        ]
+    , cmdExamples =
+        [ "info"
+        , "info shortcuts"
+        ]
+    }
diff --git a/src/FunBot/Commands/Locations.hs b/src/FunBot/Commands/Locations.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/Locations.hs
@@ -0,0 +1,357 @@
+{- This file is part of funbot.
+ -
+ - Written in 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Where, lwhere, gwhere, lwhere+, lwhere-, gwhere+, gwhere- commands
+--
+-- Manage and query locations
+module FunBot.Commands.Locations
+    ( cmdWhere
+    , cmdWhereLocal
+    , cmdWhereGlobal
+    , cmdAddWhereLocal
+    , cmdRemoveWhereLocal
+    , cmdAddWhereGlobal
+    , cmdRemoveWhereGlobal
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import Formatting ((%))
+import FunBot.History (quote, reportHistory')
+import FunBot.Locations
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels
+import FunBot.Settings.Sections.Locations
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Network.IRC.Fun.Color.Format (formatMsg)
+import Network.IRC.Fun.Color.Format.Long
+import Network.IRC.Fun.Types.Base
+import Text.Read (readMaybe)
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+--import qualified Network.IRC.Fun.Color.Format.Short as F
+
+respondLookup
+    :: (LocationLabel -> BotSession (Maybe Location))
+    -> Nickname
+    -> Text
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondLookup flookup nick labelt send = do
+    let label = LocationLabel $ CI.mk labelt
+    mloc <- flookup label
+    case mloc of
+        Nothing  -> send $ formatMsg
+            (nickname % ", location ‘" % text % "’ not found.")
+            nick labelt
+        Just loc -> send $ MsgContent $ labelt <> " : " <> unLocation loc
+
+respondLookupBoth
+    :: Channel
+    -> Nickname
+    -> Text
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondLookupBoth chan = respondLookup $ lookupBoth chan
+
+respondLookupLocal
+    :: Channel
+    -> Nickname
+    -> Text
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondLookupLocal chan = respondLookup $ lookupLocal chan
+
+respondLookupGlobal
+    :: Nickname
+    -> Text
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondLookupGlobal = respondLookup lookupGlobal
+
+respondWhere
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondWhere mchan nick [labelt] send =
+    let respond = maybe respondLookupGlobal respondLookupBoth mchan
+    in  respond nick labelt send
+respondWhere mchan nick [labelt, chant] send =
+    let chan = Channel chant
+    in  if looksLikeChan chan
+            then respondLookupBoth chan nick labelt send
+            else send $ notchan chan
+respondWhere mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) Nothing
+
+cmdWhere = Command
+    { cmdNames    = cmds ["where"]
+    , cmdRespond  = respondWhere
+    , cmdHelp     = 
+        "‘where <label>’           - get the location stored for the given \
+        \label. This checks in the per-channel location map first. If the \
+        \label isn’t found there, then the global map is checked. If used in \
+        \private chat with me, only the global map is used.\n\
+        \‘where <label> <channel>’ - the same idea, except now the \
+        \per-channel map used isn’t picked by the channel in which the command \
+        \was sent, but the channel is explicitly specified. This allows the \
+        \per-channel maps to be used in privately sent commands too."
+    , cmdExamples =
+        [ "where code"
+        , "where mumble #freepost"
+        ]
+    }
+
+respondWhereLocal
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondWhereLocal (Just chan) nick [labelt] send =
+    respondLookupLocal chan nick labelt send
+respondWhereLocal Nothing nick [labelt] send =
+    send $ MsgContent $
+        unNickname nick <>
+        ": please specify a channel, or use this command in a channel"
+respondWhereLocal mchan nick [labelt, chant] send =
+    let chan = Channel chant
+    in  if looksLikeChan chan
+            then respondLookupLocal chan nick labelt send
+            else send $ notchan chan
+respondWhereLocal mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) Nothing
+
+cmdWhereLocal = Command
+    { cmdNames    = cmds ["lwhere", "where-local"]
+    , cmdRespond  = respondWhereLocal
+    , cmdHelp     = helps
+        [ ( "lwhere <label>"
+          , "get the location stored for the given label. This checks only \
+            \the per-channel location map, according to the channel in which \
+            \the command is sent."
+          )
+        , ( "lwhere <label> <channel>"
+          , "the same idea, except now the per-channel map of the specified \
+            \channel is used. This allows the per-channel maps to be used in \
+            \privately sent commands too."
+          )
+        ]
+    , cmdExamples =
+        [ "lwhere code"
+        , "lwhere mumble #freepost"
+        ]
+    }
+
+respondWhereGlobal
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondWhereGlobal _mchan nick [labelt] send =
+    respondLookupGlobal nick labelt send
+respondWhereGlobal mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+cmdWhereGlobal = Command
+    { cmdNames    = cmds ["gwhere", "where-global"]
+    , cmdRespond  = respondWhereGlobal
+    , cmdHelp     = helps
+        [ ( "gwhere <label>"
+          , "get the location stored for the given label. This checks only \
+            \the global location map."
+          )
+        ]
+    , cmdExamples =
+        [ "gwhere issues"
+        ]
+    }
+
+respondAddWhereLocal
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondAddWhereLocal Nothing nick (_labelt : _locw@(_:_)) send =
+    send $ MsgContent $
+    unNickname nick <> ", this command works only in a channel"
+respondAddWhereLocal (Just chan) nick (labelt : locw@(_:_)) send = do
+    let label = LocationLabel $ CI.mk labelt
+        loc = Location $ T.unwords locw
+    res <- addLocalLocation chan label loc
+    case res of
+        Nothing ->
+            send $ formatMsg
+                ("Location ‘" % text % "’ registered for " % channel)
+                labelt chan
+        Just False ->
+            send $ formatMsg
+                ( nickname % ", " % channel % " isn’t one of my autojoin \
+                \channels. If you’d like to add it to my list, you should \
+                \probably talk to my maintainer. Once added, you can define \
+                \locations for this channel."
+                )
+                nick chan
+        Just True -> send $ formatMsg
+                ( nickname % ", location ‘" % text % "’ is already registered \
+                \for " % channel % ". You can use ‘!set’ to modify it."
+                )
+                nick labelt chan
+respondAddWhereLocal mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 2)
+
+cmdAddWhereLocal = Command
+    { cmdNames    =
+        cmds ["lwhere+", "where-local+", "add-lwhere", "add-where-local"]
+    , cmdRespond  = respondAddWhereLocal
+    , cmdHelp     = helps
+        [ ( "lwhere+ <label> <location>"
+          , "add a new location with the given label to the per-channel \
+            \location map of the channel in which the command is sent."
+          )
+        ]
+    , cmdExamples =
+        [ "lwhere+ fsf https://fsf.org"
+        , "lwhere+ Jane Climbing a mountain, back next week"
+        ]
+    }
+
+respondRemoveWhereLocal
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondRemoveWhereLocal Nothing nick [_labelt] send =
+    send $ MsgContent $
+    unNickname nick <> ", this command works only in a channel"
+respondRemoveWhereLocal (Just chan) nick [labelt] send = do
+    let label = LocationLabel $ CI.mk labelt
+    succ <- removeLocalLocation chan label
+    if succ
+        then send $ formatMsg
+                ("Location ‘" % text % "’ removed for " % channel)
+                labelt chan
+        else send $ formatMsg
+                ( nickname % ", can’t delete: there is no location ‘" % text
+                % "’ registered for " % channel
+                )
+                nick labelt chan
+respondRemoveWhereLocal mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+cmdRemoveWhereLocal = Command
+    { cmdNames    =
+        cmds ["lwhere-", "where-local-", "delete-lwhere", "delete-where-local"]
+    , cmdRespond  = respondRemoveWhereLocal
+    , cmdHelp     = helps
+        [ ( "lwhere- <label>"
+          , "remove the location with the given label from the per-channel \
+            \location map of the channel in which the command is sent."
+          )
+        ]
+    , cmdExamples =
+        [ "lwhere- website"
+        ]
+    }
+
+respondAddWhereGlobal
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondAddWhereGlobal _mchan nick (labelt : locw@(_:_)) send = do
+    let label = LocationLabel $ CI.mk labelt
+        loc = Location $ T.unwords locw
+    success <- addLocation label loc
+    send $ if success
+        then MsgContent $ "Global location ‘" <> labelt <> "’ registered"
+        else formatMsg
+                ( nickname % ", global location ‘" % text % "’ is already \
+                \registered. You can use ‘!set’ to modify it."
+                )
+                nick labelt
+respondAddWhereGlobal mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 2)
+
+cmdAddWhereGlobal = Command
+    { cmdNames    =
+        cmds ["gwhere+", "where-global+", "add-gwhere", "add-where-global"]
+    , cmdRespond  = respondAddWhereGlobal
+    , cmdHelp     = helps
+        [ ( "gwhere+ <label> <location>"
+          , "add a new location with the given label to the global location \
+            \map."
+          )
+        ]
+    , cmdExamples =
+        [ "gwhere+ fsf https://fsf.org"
+        , "gwhere+ John Took a flight to the moon, back next week"
+        ]
+    }
+
+respondRemoveWhereGlobal
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondRemoveWhereGlobal _mchan nick [labelt] send = do
+    let label = LocationLabel $ CI.mk labelt
+    success <- removeLocation label
+    send $ if success
+        then MsgContent $ "Global location ‘" <> labelt <> "’ removed"
+        else formatMsg
+                ( nickname % ", can’t delete: there is no global location ‘"
+                % text % "’ registered"
+                )
+                nick labelt
+respondRemoveWhereGlobal mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+cmdRemoveWhereGlobal = Command
+    { cmdNames    = cmds
+        ["gwhere-", "where-global-", "delete-gwhere", "delete-where-global"]
+    , cmdRespond  = respondRemoveWhereGlobal
+    , cmdHelp     = helps
+        [ ( "gwhere- <label>"
+          , "remove the location with the given label from the global \
+            \location map."
+          )
+        ]
+    , cmdExamples =
+        [ "gwhere- githu8"
+        ]
+    }
diff --git a/src/FunBot/Commands/Memos.hs b/src/FunBot/Commands/Memos.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/Memos.hs
@@ -0,0 +1,116 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Tell command
+--
+-- Tell something to some other user
+module FunBot.Commands.Memos
+    ( cmdPTell
+    , cmdCTell
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import FunBot.History (quote, reportHistory')
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels (addChannel)
+import FunBot.Settings.Sections.Feeds (addFeed, deleteFeed)
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts (addShortcut, deleteShortcut)
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Text.Read (readMaybe)
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+
+-- Given whether to always send privately, return a command response
+respondTell
+    :: Bool
+    -> Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondTell priv mchan sender (recip : ws@(_:_)) send =
+    let unsnoc t =
+            if T.null t
+                then Nothing
+                else Just (T.init t, T.last t)
+        recip' =
+            case unsnoc recip of
+                Just (r, ':') -> r
+                Just (r, ',') -> r
+                _             -> recip
+        msg = MsgContent $ T.unwords ws
+    in  if looksLikeNick recip'
+            then submitMemo sender mchan (Nickname recip') priv msg
+            else send $ notnick recip'
+respondTell _priv mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) Nothing
+
+cmdPTell = Command
+    { cmdNames    = cmds ["tell", "ptell"]
+    , cmdRespond  = respondTell True
+    , cmdHelp     = helps
+        [ ( "tell <nick>[:|,] <text>"
+          , "leave a memo for a user to see later. Memos can be sent to the \
+            \recipient privately, or publicly in the channel in which they \
+            \were submitted. With this command, the memo will be sent \
+            \privately. If that isn't your intention, see the ctell command."
+          )
+        ]
+    , cmdExamples =
+        [ "tell johndoe Hello! How are you?"
+        , "tell johndoe, Hello! How are you?"
+        , "tell johndoe: Hello! How are you?"
+        ]
+    }
+
+cmdCTell = Command
+    { cmdNames    = cmds ["ctell"]
+    , cmdRespond  = respondTell False
+    , cmdHelp     = helps
+        [ ( "tell <nick>[:|,] <text>"
+          , "leave a memo for a user to see later. Memos can be sent to the \
+            \recipient privately, or publicly in the channel in which they \
+            \were submitted. With this command, the memo will be sent in the \
+            \same way it was submitted: If you submit it in a channel, it \
+            \will be sent to the recipient in the same channel. If you submit \
+            \using a private message to me, I will also send it privately to \
+            \the recipient.\n\
+            \If that isn't your intention, see the tell command."
+          )
+        ]
+    , cmdExamples =
+        [ "ctell johndoe Hello! How are you?"
+        , "ctell johndoe, Hello! How are you?"
+        , "ctell johndoe: Hello! How are you?"
+        ]
+    }
diff --git a/src/FunBot/Commands/Misc.hs b/src/FunBot/Commands/Misc.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/Misc.hs
@@ -0,0 +1,107 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Commands.Misc
+    ( cmdEcho
+    , cmdQuote
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import FunBot.History (quote, reportHistory')
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels (addChannel)
+import FunBot.Settings.Sections.Feeds (addFeed, deleteFeed)
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts (addShortcut, deleteShortcut)
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util (getHistoryLines, cmds, helps)
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Text.Read (readMaybe)
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+
+-------------------------------------------------------------------------------
+-- Echo command
+-- Send the input back to the IRC channel
+-------------------------------------------------------------------------------
+
+respondEcho
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondEcho _mchan _nick []      send = send $ MsgContent " "
+respondEcho _mchan _nick [param] send = send $ MsgContent param
+respondEcho _mchan _nick params  send = send $ MsgContent $ T.unwords params
+
+cmdEcho = Command
+    { cmdNames    = cmds ["echo"]
+    , cmdRespond  = respondEcho
+    , cmdHelp     = helps
+        [ ( "echo <text>"
+          , "display the given text. Probably not a useful command. Exists as \
+            \an example and for testing."
+          )
+        ]
+    , cmdExamples =
+        [ "echo This is a test"
+        ]
+    }
+
+-------------------------------------------------------------------------------
+-- Quote command
+-- Record something someone said
+-------------------------------------------------------------------------------
+
+respondQuote
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondQuote (Just chan) _nick [target] _send = quote chan $ Nickname target
+respondQuote Nothing     _nick [_]      _send = return ()
+respondQuote mchan       nick  args     _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+cmdQuote = Command
+    { cmdNames    = cmds ["quote"]
+    , cmdRespond  = respondQuote
+    , cmdHelp     = helps
+        [ ( "quote <nick>"
+          , "record the last message sent by <nick> in the channel. The list \
+            \of past quotes is publicly available."
+          )
+        ]
+    , cmdExamples =
+        [ "quote johndoe"
+        ]
+    }
diff --git a/src/FunBot/Commands/Puppet.hs b/src/FunBot/Commands/Puppet.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/Puppet.hs
@@ -0,0 +1,200 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Commands.Puppet
+    ( cmdPuppetStart
+    , cmdPuppetEnd
+    --, cmdPuppetStatus
+    , cmdPuppetSay
+    , cmdPuppetEcho
+    )
+where
+
+import Formatting ((%))
+import FunBot.Puppet
+import FunBot.Types
+import FunBot.Util
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.Types
+import Network.IRC.Fun.Color.Format (formatMsg)
+import Network.IRC.Fun.Color.Format.Long
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.Text as T
+
+start :: Channel -> Nickname -> (MsgContent -> BotSession ()) -> BotSession ()
+start chan nick send = do
+    result <- puppetStart chan nick
+    send $ case result of
+        Just False ->
+            formatMsg
+                (nickname % ", I already have " % channel % " in puppet mode.")
+                nick chan
+        Just True ->
+            formatMsg
+                ( nickname
+                % ", you aren’t listed as a puppeteer for "
+                % channel
+                )
+                nick chan
+        Nothing   ->
+            formatMsg
+                (nickname % ", puppet mode started for " % channel)
+                nick chan
+
+respondPuppetStart :: Respond
+respondPuppetStart Nothing _nick [] send =
+    send "Please specify a channel, or use this command in a channel."
+respondPuppetStart (Just chan) nick [] send = start chan nick send
+respondPuppetStart _mchan nick [chant] send =
+    let chan = Channel chant
+    in  if looksLikeChan chan
+            then start chan nick send
+            else send $ notchan chan
+respondPuppetStart mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) Nothing
+
+cmdPuppetStart :: BotCmd
+cmdPuppetStart = Command
+    { cmdNames    = cmds ["puppet-start"]
+    , cmdRespond  = respondPuppetStart
+    , cmdHelp     = helps
+        [ ( "puppet-start"
+          , "enable puppet mode in the current channel"
+          )
+        , ( "puppet-start <channel>"
+          , "enable puppet mode in the given channel"
+          )
+        ]
+    , cmdExamples =
+        [ "puppet-start"
+        , "puppet-start #freepost-bot"
+        ]
+    }
+
+end :: Channel -> Nickname -> (MsgContent -> BotSession ()) -> BotSession ()
+end chan nick send = do
+    result <- puppetEnd chan nick
+    send $ case result of
+        Just False ->
+            formatMsg
+                (nickname % ", I don’t have " % channel % " in puppet mode.")
+                nick chan
+        Just True ->
+            formatMsg
+                ( nickname
+                % ", you aren’t listed as a puppeteer for "
+                % channel
+                )
+                nick chan
+        Nothing   ->
+            formatMsg
+                (nickname % ", puppet mode ended for " % channel)
+                nick chan
+
+respondPuppetEnd :: Respond
+respondPuppetEnd Nothing _nick [] send =
+    send "Please specify a channel, or use this command in a channel."
+respondPuppetEnd (Just chan) nick [] send = end chan nick send
+respondPuppetEnd _mchan nick [chant] send =
+    let chan = Channel chant
+    in  if looksLikeChan chan
+            then end chan nick send
+            else send $ notchan chan
+respondPuppetEnd mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) Nothing
+
+cmdPuppetEnd :: BotCmd
+cmdPuppetEnd = Command
+    { cmdNames    = cmds ["puppet-end"]
+    , cmdRespond  = respondPuppetEnd
+    , cmdHelp     = helps
+        [ ( "puppet-end"
+          , "stop puppet mode in the current channel"
+          )
+        , ( "puppet-end <channel>"
+          , "stop puppet mode in the given channel"
+          )
+        ]
+    , cmdExamples =
+        [ "puppet-end"
+        , "puppet-end #freepost-bot"
+        ]
+    }
+
+respondPuppetSay :: Bool -> Respond
+respondPuppetSay reveal _mchan nick (chant : ws@(_:_)) send =
+    let chan = Channel chant
+    in  if looksLikeChan chan
+            then do
+                let msg = MsgContent $ T.unwords ws
+                result <- puppetSay chan nick msg reveal
+                send $ case result of
+                    Just False ->
+                        formatMsg
+                            ( nickname
+                            % ", I don’t have "
+                            % channel
+                            % " in puppet mode."
+                            )
+                            nick chan
+                    Just True ->
+                        formatMsg
+                            ( nickname
+                            % ", you didn’t start puppet mode for "
+                            % channel
+                            )
+                            nick chan
+                    Nothing ->
+                        formatMsg
+                            (nickname % ", I sent the message to " % channel)
+                            nick chan
+            else send $ notchan chan
+respondPuppetSay _reveal mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 2)
+
+cmdPuppetSay :: BotCmd
+cmdPuppetSay = Command
+    { cmdNames    = cmds ["puppet-say"]
+    , cmdRespond  = respondPuppetSay True
+    , cmdHelp     = helps
+        [ ( "puppet-say <channel> <message>"
+          , "while in puppet mode, ask me to say something in the channel. I \
+            \will reveal the message comes from you. If you don’t want that, \
+            \see the puppet-echo command."
+          )
+        ]
+    , cmdExamples =
+        [ "puppet-say #freepost Hello world!"
+        ]
+    }
+
+cmdPuppetEcho :: BotCmd
+cmdPuppetEcho = Command
+    { cmdNames    = cmds ["puppet-echo"]
+    , cmdRespond  = respondPuppetSay False
+    , cmdHelp     = helps
+        [ ( "puppet-echo <channel> <message>"
+          , "while in puppet mode, ask me to say something in the channel. I \
+            \won’t reveal the message comes from you. Also see the puppet-say \
+            \command."
+          )
+        ]
+    , cmdExamples =
+        [ "puppet-echo #freepost Hello world!"
+        ]
+    }
diff --git a/src/FunBot/Commands/Repos.hs b/src/FunBot/Commands/Repos.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/Repos.hs
@@ -0,0 +1,213 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Add-spec, delete-spec, add-repo, delete-repo commands
+--
+-- Manage git repo event announcement details
+module FunBot.Commands.Repos
+    ( cmdAddSpec
+    , cmdDeleteSpec
+    , cmdAddRepo
+    , cmdDeleteRepo
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import FunBot.History (quote, reportHistory')
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels (addChannel)
+import FunBot.Settings.Sections.Feeds (addFeed, deleteFeed)
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts (addShortcut, deleteShortcut)
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util (getHistoryLines, cmds, helps)
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Text.Read (readMaybe)
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+
+respondAddSpec
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondAddSpec mchan nick [host, space, repo, chan] send = do
+    succ <-
+        addRepoAnnSpec
+            (DevHostLabel host)
+            (RepoSpace $ CI.mk space)
+            (RepoName $ CI.mk repo)
+            (Channel chan)
+    if succ
+        then send $ MsgContent "Git event announcement spec added."
+        else failBack mchan nick $ OtherFail "Repo not found."
+respondAddSpec mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 4)
+
+cmdAddSpec = Command
+    { cmdNames    = cmds ["spec+", "add-spec"]
+    , cmdRespond  = respondAddSpec
+    , cmdHelp     = helps
+        [ ( "spec+ <host> <space> <repo> <channel>"
+          , "add a channel to which git repo events will be announced, with \
+            \default settings"
+          )
+        ]
+    , cmdExamples =
+        [ "spec+ notabug SylvieLorxu CSSBox #freepost"
+        ]
+    }
+
+respondDeleteSpec
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondDeleteSpec mchan nick [host, space, repo, num] send =
+    case TR.signed TR.decimal num of
+        Right (pos, "") ->
+            if pos < 1
+                then failBack mchan nick $ InvalidArg (Just 4) (Just num)
+                else do
+                    res <-
+                        deleteRepoAnnSpec
+                            (DevHostLabel host)
+                            (RepoSpace $ CI.mk space)
+                            (RepoName $ CI.mk repo)
+                            (pos - 1)
+                    case res of
+                        Nothing ->
+                            send $ MsgContent $
+                                "Git event announcement spec " <>
+                                num                            <>
+                                " removed."
+                        Just False ->
+                            failBack mchan nick $
+                            OtherFail "Repo not found."
+                        Just True  ->
+                            failBack mchan nick $
+                            OtherFail "Spec number out of range."
+        _ -> failBack mchan nick $ InvalidArg (Just 3) (Just num)
+respondDeleteSpec mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 3)
+
+cmdDeleteSpec = Command
+    { cmdNames    = cmds ["spec-", "delete-spec"]
+    , cmdRespond  = respondDeleteSpec
+    , cmdHelp     = helps
+        [ ( "spec- <host> <space> <repo> <num>"
+          , "remove a git event announcement specification with the given \
+            \index number (as found in the settings tree, i.e. starting from \
+            \1) from the given repo."
+          )
+        ]
+    , cmdExamples =
+        [ "spec- notabug fr33domlover oldstuff 1"
+        ]
+    }
+
+invalid :: Text -> Bool
+invalid t = T.null t || T.any (== '/') t
+
+respondAddRepo
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondAddRepo mchan nick [host, space, repo, chan] send
+    | invalid repo =
+        failBack mchan nick $ InvalidArg (Just 3) (Just repo)
+    | invalid space =
+        failBack mchan nick $ InvalidArg (Just 2) (Just space)
+    | otherwise = do
+        res <-
+            addRepo
+                (DevHostLabel host)
+                (RepoSpace $ CI.mk space)
+                (RepoName $ CI.mk repo)
+                (Channel chan)
+        case res of
+            Nothing -> send $ MsgContent "Git repo added."
+            Just False ->
+                failBack mchan nick $
+                OtherFail "This host label isn’t registered."
+            Just True ->
+                failBack mchan nick $
+                OtherFail "This repo is already registered."
+respondAddRepo mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 4)
+
+cmdAddRepo = Command
+    { cmdNames    = cmds ["repo+", "add-repo"]
+    , cmdRespond  = respondAddRepo
+    , cmdHelp     = helps
+        [ ( "repo+ <host> <space> <repo> <channel>"
+          , "add a repo to announce its events in the given channel, with \
+            \default settings."
+          )
+        ]
+    , cmdExamples =
+        [ "repo+ notabug Tsyesika Federated-MediaGoblin #mediagoblin"
+        ]
+    }
+
+respondDeleteRepo
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondDeleteRepo mchan nick [host, space, repo] send = do
+    succ <-
+        deleteRepo
+            (DevHostLabel host)
+            (RepoSpace $ CI.mk space)
+            (RepoName $ CI.mk repo)
+    if succ
+        then send $ MsgContent "Git repo removed."
+        else failBack mchan nick $ OtherFail "No such repo/owner pair found."
+respondDeleteRepo mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 3)
+
+cmdDeleteRepo = Command
+    { cmdNames    = cmds ["repo-", "delete-repo"]
+    , cmdRespond  = respondDeleteRepo
+    , cmdHelp     = helps
+        [ ( "repo- <host> <space> <repo>"
+          , "stop announcing events for the given repo and remove its \
+            \settings."
+          )
+        ]
+    , cmdExamples =
+        [ "repo- notabug fr33domlover oldstuff"
+        ]
+    }
diff --git a/src/FunBot/Commands/Settings.hs b/src/FunBot/Commands/Settings.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/Settings.hs
@@ -0,0 +1,179 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Get, set, enable and disable commands
+--
+-- Manage bot settings
+module FunBot.Commands.Settings
+    ( cmdGet
+    , cmdSet
+    , cmdReset
+    , cmdEnable
+    , cmdDisable
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import FunBot.History (quote, reportHistory')
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels (addChannel)
+import FunBot.Settings.Sections.Feeds (addFeed, deleteFeed)
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts (addShortcut, deleteShortcut)
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util (getHistoryLines, cmds, helps)
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Text.Read (readMaybe)
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+
+respondGet
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondGet _mchan _nick []     send  = respondGet' "" send
+respondGet _mchan _nick [path] send  = respondGet' path send
+respondGet mchan  nick  args   _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+respondSet
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondSet _mchan _nick [name, val] send  = respondSet' name val send
+respondSet mchan  nick  args        _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 2)
+
+respondReset
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondReset _mchan _nick [name] send  = respondReset' name send
+respondReset mchan  nick  args   _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+-- Given a boolean, create an enable/disable response accordingly
+respondBool
+    :: Bool
+    -> Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondBool val  _mchan _nick [name] send  =
+    respondSet' name (showOption val) send
+respondBool _val mchan  nick  args   _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+respondEnable
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondEnable = respondBool True
+
+respondDisable
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondDisable = respondBool False
+
+cmdGet = Command
+    { cmdNames    = cmds ["get"]
+    , cmdRespond  = respondGet
+    , cmdHelp     = helps
+        [ ( "get <option>"
+          , "get the value of a settings option at the given path"
+          )
+        ]
+    , cmdExamples =
+        [ "get channels.#freepost.welcome"
+        ]
+    }
+
+cmdSet = Command
+    { cmdNames    = cmds ["set"]
+    , cmdRespond  = respondSet
+    , cmdHelp     = helps
+        [ ( "set <option> <value>"
+          , "set a settings option at the given path to the given value"
+          )
+        ]
+    , cmdExamples =
+        [ "set channels.#freepost.welcome yes"
+        ]
+    }
+
+cmdReset = Command
+    { cmdNames    = cmds ["reset"]
+    , cmdRespond  = respondReset
+    , cmdHelp     = helps
+        [ ( "reset <option>"
+          , "set a settings option at the given path to its default value"
+          )
+        ]
+    , cmdExamples =
+        [ "reset channels.#freepost.welcome"
+        ]
+    }
+
+cmdEnable = Command
+    { cmdNames    = cmds ["enable"]
+    , cmdRespond  = respondEnable
+    , cmdHelp     = helps
+        [ ( "enable <option>"
+          , "set a settings option at the given path to True"
+          )
+        ]
+    , cmdExamples =
+        [ "enable channels.#freepost.welcome"
+        ]
+    }
+
+cmdDisable = Command
+    { cmdNames    = cmds ["disable"]
+    , cmdRespond  = respondDisable
+    , cmdHelp     = helps
+        [ ( "disable <option>"
+          , "set a settings option at the given path to False"
+          )
+        ]
+    , cmdExamples =
+        [ "disable channels.#freepost.welcome"
+        ]
+    }
diff --git a/src/FunBot/Commands/Shortcuts.hs b/src/FunBot/Commands/Shortcuts.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/Shortcuts.hs
@@ -0,0 +1,113 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Add-shortcut, delete-shortcut commands
+--
+-- Manage shortcuts
+module FunBot.Commands.Shortcuts
+    ( cmdAddShortcut
+    , cmdDeleteShortcut
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import FunBot.History (quote, reportHistory')
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels (addChannel)
+import FunBot.Settings.Sections.Feeds (addFeed, deleteFeed)
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts (addShortcut, deleteShortcut)
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Text.Read (readMaybe)
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+
+respondAddShortcut
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondAddShortcut mchan nick [label, chan] send =
+    if looksLikeChan $ Channel chan
+        then do
+            succ <- addShortcut (ShortcutLabel $ CI.mk label) (Channel chan)
+            if succ
+                then send $ MsgContent "Shortcut added."
+                else failBack mchan nick $ OtherFail
+                     "This shortcut is already registered."
+        else send $ notchan $ Channel chan
+respondAddShortcut mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 2)
+
+cmdAddShortcut = Command
+    { cmdNames    = cmds ["shortcut+", "add-shortcut"]
+    , cmdRespond  = respondAddShortcut
+    , cmdHelp     = helps
+        [ ( "shortcut+ <label> <channel>"
+          , "add a shortcut to apply in the given channel, with default dummy \
+            \settings. The label is just for convenient reference; pick a \
+            \short meaningful one."
+          )
+        ]
+    , cmdExamples =
+        [ "shortcut+ issue #snowdrift"
+        ]
+    }
+
+respondDeleteShortcut
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondDeleteShortcut mchan nick [label] send = do
+    succ <- deleteShortcut $ ShortcutLabel $ CI.mk label
+    if succ
+        then send $ MsgContent "Shortcut removed."
+        else failBack mchan nick $ OtherFail "No such shortcut found."
+respondDeleteShortcut mchan nick args _send =
+    failBack mchan nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+cmdDeleteShortcut = Command
+    { cmdNames    = cmds ["shortcut-", "delete-shortcut"]
+    , cmdRespond  = respondDeleteShortcut
+    , cmdHelp     = helps
+        [ ( "shortcut- <label>"
+          , "remove the shortcut with the given label completely. If you just \
+            \want to change the channels in which it applies, see the \
+            \“shortcuts.<label>.channels” settings option."
+          )
+        ]
+    , cmdExamples =
+        [ "shortcut- issue"
+        ]
+    }
diff --git a/src/FunBot/Commands/UserOptions.hs b/src/FunBot/Commands/UserOptions.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Commands/UserOptions.hs
@@ -0,0 +1,193 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Show-opts, enable-history, disable-history, set-history-lines,
+-- get-history-lines, erase-opts commands
+--
+-- Manage user options
+module FunBot.Commands.UserOptions
+    ( cmdShowOpts
+    , cmdEnableHistory
+    , cmdDisableHistory
+    , cmdSetLines
+    , cmdEraseOpts
+    )
+where
+
+import Control.Monad (unless, when)
+import Data.List (find, intercalate)
+import Data.Monoid ((<>))
+import Data.Settings.Types (showOption)
+import Data.Text (Text)
+import FunBot.History (quote, reportHistory')
+import FunBot.Memos (submitMemo)
+import FunBot.Settings
+import FunBot.Settings.Sections.Channels (addChannel)
+import FunBot.Settings.Sections.Feeds (addFeed, deleteFeed)
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts (addShortcut, deleteShortcut)
+import FunBot.Types
+import FunBot.UserOptions
+import FunBot.Util
+import Network.IRC.Fun.Bot.Behavior
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types
+import Text.Read (readMaybe)
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
+import qualified Data.Text.Read as TR
+
+priv = MsgContent "That command works only in private conversation with me."
+
+respondShowOpts
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondShowOpts (Just _chan) _nick _args  send  = send priv
+respondShowOpts Nothing      nick  []     _send = sendChannels nick
+respondShowOpts Nothing      nick  [chan] send  =
+    if looksLikeChan $ Channel chan
+        then sendHistoryOpts nick $ Channel chan
+        else send $ notchan $ Channel chan
+respondShowOpts Nothing      nick  args   _send =
+    failToUser nick $ WrongNumArgsN (Just $ length args) Nothing
+
+cmdShowOpts = Command
+    { cmdNames    = cmds ["show-opts", "show-options"]
+    , cmdRespond  = respondShowOpts
+    , cmdHelp     = helps
+        [ ( "show-opts"
+          , "list channels for which you set history display options."
+          )
+        , ( "show-opts <channel>"
+          , "show history display options for the given channel."
+          )
+        ]
+    , cmdExamples =
+        [ "show-opts"
+        , "show-opts #snowdrift"
+        ]
+    }
+
+respondHistory
+    :: Bool
+    -> Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondHistory _enable (Just _chan) _nick _args  send  = send priv
+respondHistory enable  Nothing      nick  [chan] send  =
+    if looksLikeChan $ Channel chan
+        then do
+            setEnabled nick (Channel chan) enable
+            hls <- getHistoryLines $ Channel chan
+            when (enable && hls < 1) $ send $ MsgContent
+                "Note that while you enabled personal history display, \
+                \history logging is disabled for that channel, therefore you \
+                \won’t be getting history messages. Ask the bot maintainer(s) \
+                \to enable it."
+        else send $ notchan $ Channel chan
+respondHistory _enable Nothing      nick  args   _send =
+    failToUser nick $ WrongNumArgsN (Just $ length args) (Just 1)
+
+cmdEnableHistory = Command
+    { cmdNames    = cmds ["history+", "enable-history"]
+    , cmdRespond  = respondHistory True
+    , cmdHelp     = helps
+        [ ( "history+ <channel>"
+          , "enable automatic history private display for the given channel."
+          )
+        ]
+    , cmdExamples =
+        [ "history+ #snowdrift"
+        ]
+    }
+
+cmdDisableHistory = Command
+    { cmdNames    = cmds ["history-", "disable-history"]
+    , cmdRespond  = respondHistory False
+    , cmdHelp     = helps
+        [ ( "history- <channel>"
+          , "disable automatic history private display for the given channel."
+          )
+        ]
+    , cmdExamples =
+        [ "history- #snowdrift-test"
+        ]
+    }
+
+respondSetLines
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondSetLines (Just _chan) _nick _args       send  = send priv
+respondSetLines Nothing      nick  [chan, len] send  =
+    if looksLikeChan $ Channel chan
+        then
+            case TR.decimal len of
+                Right (n, "") -> setMaxLines nick (Channel chan) n
+                _             -> badLen
+        else send $ notchan $ Channel chan
+    where
+    badLen = failToUser nick $ InvalidArg (Just 2) (Just len)
+respondSetLines Nothing      nick  args        _send =
+    failToUser nick $ WrongNumArgsN (Just $ length args) (Just 2)
+
+cmdSetLines = Command
+    { cmdNames    = cmds ["set-history-lines"]
+    , cmdRespond  = respondSetLines
+    , cmdHelp     = helps
+        [ ( "set-history-lines <channel> <num>"
+          , "set the maximal number of channel history lines to display."
+          )
+        ]
+    , cmdExamples =
+        [ "set-history-lines #snowdrift 10"
+        ]
+    }
+
+respondEraseOpts
+    :: Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondEraseOpts (Just _chan) _nick _args send  = send priv
+respondEraseOpts Nothing      nick  []    _send = eraseOpts nick
+respondEraseOpts Nothing      nick  args  _send =
+    failToUser nick $ WrongNumArgsN (Just $ length args) (Just 0)
+
+cmdEraseOpts = Command
+    { cmdNames    = cmds ["erase-opts", "erase-options"]
+    , cmdRespond  = respondEraseOpts
+    , cmdHelp     = helps
+        [ ( "erase-opts"
+          , "reset all your options back to defaults."
+          )
+        ]
+    , cmdExamples =
+        [ "erase-opts"
+        ]
+    }
diff --git a/src/FunBot/Config.hs b/src/FunBot/Config.hs
--- a/src/FunBot/Config.hs
+++ b/src/FunBot/Config.hs
@@ -1,6 +1,6 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
  -
  - ♡ Copying is an act of love. Please copy, reuse and share.
  -
@@ -13,10 +13,13 @@
  - <http://creativecommons.org/publicdomain/zero/1.0/>.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module FunBot.Config
     ( stateSaveInterval
     , configuration
     , webListenerPort
+    , webErrorLogFile
     , feedErrorLogFile
     , feedVisitInterval
     , settingsFilename
@@ -24,36 +27,41 @@
     , userOptsFilename
     , nicksFilename
     , quoteDir
+    , introInfo
+    , welcomeDelay
     )
 where
 
+import Data.Default.Class (def)
+import Data.Text (Text)
 import Data.Time.Interval (time)
 import Data.Time.Units
-import Network.IRC.Fun.Bot (defConfig)
-import Network.IRC.Fun.Bot.Types (Connection (..), Config (..))
+import Network.IRC.Fun.Bot.Types
+import Network.IRC.Fun.Client.IO
+import Network.IRC.Fun.Types.Base
 
 stateSaveInterval = 3 :: Second
 
-configuration = defConfig
-    { cfgConnection = Connection
-        { connServer   = "irc.freenode.net"
-        , connPort     = 6667
-        , connTls      = False -- not supported yet
-        , connNick     = "bot_test_joe"
+configuration = def
+    { cfgConnection = def
+        { connServer   = Hostname "irc.freenode.net"
+        , connPort     = PortNumber 6667
+        , connTls      = False
+        , connNickname = Nickname "bot_test_joe"
         , connPassword = Nothing
         }
-    , cfgChannels        = ["#freepost-bot-test"]
+    , cfgChannels        = map Channel ["#freepost-bot-test"]
     , cfgLogDir          = "state/chanlogs"
     , cfgStateRepo       = Nothing
     , cfgStateFile       = "state/state.json"
     , cfgSaveInterval    = time stateSaveInterval
-    , cfgBotEventLogFile = "state/bot.log"
-    , cfgIrcErrorLogFile = Nothing
     , cfgMaxMsgChars     = Just 400
     }
 
 webListenerPort = 8998 :: Int
 
+webErrorLogFile = "state/web-error.log"
+
 feedErrorLogFile = "state/feed-error.log"
 
 feedVisitInterval = 5 :: Minute
@@ -64,23 +72,24 @@
 -- dir (or absolute), and Git won't be used.
 settingsFilename = "state/settings.json"
 
--- | If you set a repo path in the configuration above ('stateRepo' field),
--- then this path is relative to that repo and the memos file will be commited
--- to Git. Otherwise, this path is relative to the bot process working dir (or
--- absolute), and Git won't be used.
+-- | Same idea, for memos.
 memosFilename = "state/memos.json"
 
--- | If you set a repo path in the configuration above ('stateRepo' field),
--- then this path is relative to that repo and the user options file will be
--- commited to Git. Otherwise, this path is relative to the bot process working
--- dir (or absolute), and Git won't be used.
+-- | Same idea, for user options.
 userOptsFilename = "state/user-options.json"
 
--- | If you set a repo path in the configuration above ('stateRepo' field),
--- then this path is relative to that repo and the nicks file will be commited
--- to Git. Otherwise, this path is relative to the bot process working dir (or
--- absolute), and Git won't be used.
+-- | Same idea, for known nickname lists.
 nicksFilename = "state/nicks.json"
 
 -- | Directory in which to place channel quotes.
 quoteDir = "state/quotes"
+
+-- | Introductory information for use by the @!intro@ command.
+introInfo :: Text
+introInfo =
+    "Hello. I’m an instance of FunBot, written in Haskell. No other info, \
+    \sorry. Ask the bot maintainer to write something here."
+
+-- | How much silent time the bot waits before it considers a channel quiet and
+-- welcomes a new user. This is in seconds.
+welcomeDelay = 60 :: Int
diff --git a/src/FunBot/ExtHandlers.hs b/src/FunBot/ExtHandlers.hs
--- a/src/FunBot/ExtHandlers.hs
+++ b/src/FunBot/ExtHandlers.hs
@@ -1,6 +1,6 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
  -
  - ♡ Copying is an act of love. Please copy, reuse and share.
  -
@@ -24,8 +24,13 @@
 import Control.Monad.IO.Class (liftIO)
 import Data.Char (toLower)
 import Data.Maybe (fromMaybe)
+import Data.Foldable (mapM_)
 import Data.Monoid ((<>))
+import Data.Sequence (Seq)
+import Data.Text (Text)
 import Data.Time.Clock (diffUTCTime)
+import Formatting hiding (text)
+import FunBot.Config (welcomeDelay)
 import FunBot.ExtEvents
 import FunBot.Types
 import FunBot.Util (passes)
@@ -34,50 +39,74 @@
 import Network.IRC.Fun.Bot.Nicks (channelIsTracked, isInChannel)
 import Network.IRC.Fun.Bot.State
 import Network.IRC.Fun.Color
-import Text.Printf (printf)
+import Network.IRC.Fun.Types.Base
+import Prelude hiding (mapM_)
 
+import qualified Data.CaseInsensitive as CI
 import qualified Data.HashMap.Lazy as M
 import qualified Data.Text as T
+import qualified Data.Text.IO as TIO
 import qualified Web.Hook.GitLab as GitLab
 import qualified Web.Hook.Gogs as Gogs
 
-makeEllip len =
-    let ellip = "..."
-        n = (len - 1) `div` 2 - 1
-        padl = replicate n ' '
-        m = len - length padl - length ellip
-        padr = replicate m ' '
-    in  padl ++ ellip ++ padr
+makeEllip :: Int -> Text
+makeEllip len = T.center len ' ' "..."
 
-formatCommit branch repo (Commit author msg url) =
-    encode $
-        Green #> Pure author <> " " <>
-        Maroon #> Pure branch <> " " <>
-        Purple #> Pure repo <> " | " <>
-        Teal #> Pure msg <> " " <>
-        Gray #> Pure url
+formatCommit :: BranchName -> RepoName -> Commit -> MsgContent
+formatCommit branch repo c =
+    MsgContent $ encode $
+        Green  #> plain (commitAuthor c)                <> " " <>
+        Maroon #> plain (unBranchName branch)           <> " " <>
+        Purple #> plain (CI.original $ unRepoName repo) <> " | " <>
+        Teal   #> plain (commitTitle c)                 <> " " <>
+        Gray   #> plain (commitUrl c)
 
+formatEllipsis :: Int -> BranchName -> RepoName -> Int -> MsgContent
 formatEllipsis len branch repo n =
-    encode $
-        Green #> Pure (makeEllip len) <> " " <>
-        Maroon #> Pure branch <> " " <>
-        Purple #> Pure repo <> " | " <>
-        Navy #> Pure ("... another " ++ show n ++ " commits ...")
+    MsgContent $ encode $
+        Green  #> plain (makeEllip len)                 <> " " <>
+        Maroon #> plain (unBranchName branch)           <> " " <>
+        Purple #> plain (CI.original $ unRepoName repo) <> " | " <>
+        Navy   #> plain (sformat ("... another " % int % " commits ...") n)
 
-formatTag (Tag author ref repo _owner) =
-    encode $
-        Green #> Pure author <> " " <>
-        Purple #> Pure repo <> " " <>
-        Teal #> Pure ref
+formatTag :: ProjectObject Tag -> MsgContent
+formatTag (ProjectObject repo tag) =
+    MsgContent $ encode $
+        Green  #> plain (tagAuthor tag) <> " " <>
+        Purple #> plain (repoName repo) <> " " <>
+        Teal   #> plain (tagRef tag)
 
-formatMR (MergeRequest author iid _repo _owner title url action) =
-    encode $
-        Green #> Pure author <> " " <>
-        Maroon #> Pure action <> " " <>
-        Purple #> Pure ('#' : show iid) <> " | " <>
-        Teal #> Pure title <> " " <>
-        Gray #> Pure url
+formatMR :: ProjectObject MergeRequest -> MsgContent
+formatMR (ProjectObject repo mr) =
+    MsgContent $ encode $
+        Green  #> plain (mrAuthor mr)                   <> " " <>
+        Maroon #> (plain $ mrAction mr <> " MR")        <> " " <>
+        Orange #> plain (sformat ("#" % int) (mrId mr)) <> " " <>
+        Purple #> plain (repoName repo)                 <> " | " <>
+        Teal   #> plain (mrTitle mr)                    <> " " <>
+        Gray   #> plain (mrUrl mr)
 
+formatIssue :: ProjectObject Issue -> MsgContent
+formatIssue (ProjectObject repo i) =
+    MsgContent $ encode $
+        Green  #> plain (issueAuthor i)                   <> " " <>
+        Maroon #> (plain $ issueAction i <> " issue")     <> " " <>
+        Orange #> plain (sformat ("#" % int) (issueId i)) <> " " <>
+        Purple #> plain (repoName repo)                   <> " | " <>
+        Teal   #> plain (issueTitle i)                    <> " " <>
+        Gray   #> plain (issueUrl i)
+
+formatNote :: ProjectObject Note -> MsgContent
+formatNote (ProjectObject repo n) =
+    MsgContent $ encode $
+        Green  #> plain (noteAuthor n)  <>
+        Maroon #> " commented on "     <>
+        Orange #> plain (noteTarget n)  <> " " <>
+        Purple #> plain (repoName repo) <> " | " <>
+        Teal   #> plain (noteContent n) <> " " <>
+        Gray   #> plain (noteUrl n)
+
+formatNews :: NewsItem -> NewsItemFields -> MsgContent
 formatNews item fields =
     let -- Filtered fields
         filt pass val = if pass then val else Nothing
@@ -85,10 +114,10 @@
         fTitleF = filt (dispFeedTitle fields) (itemFeedTitle item)
         urlF = filt (dispUrl fields) (itemUrl item)
         -- Separate components
-        author = fmap (\ a -> Green #> Pure a) authorF
-        fTitle = fmap (\ ft -> Purple #> Pure ft) fTitleF
-        iTitle = Teal #> Pure (itemTitle item)
-        url = fmap (\ u -> Gray #> Pure u) urlF
+        author = fmap (\ a -> Green #> plain a) authorF
+        fTitle = fmap (\ ft -> Purple #> plain ft) fTitleF
+        iTitle = Teal #> plain (itemTitle item)
+        url = fmap (\ u -> Gray #> plain u) urlF
         -- Now combine them
         af = case (author, fTitle) of
                 (Nothing,    Nothing)    -> Nothing
@@ -98,25 +127,28 @@
         iu = case url of
                 Nothing -> iTitle
                 Just u  -> iTitle <> " " <> u
-    in  encode $ case af of
+    in  MsgContent $ encode $ case af of
             Nothing  -> iu
             Just af' -> af' <> " | " <> iu
 
-formatPaste (Paste author verb title url _chan) =
-    printf "%v %v “%v” | %v" author verb title url
-
-lower = map toLower
-
-keyb b = (branchRepo b, lower $ branchRepoOwner b)
-
-keyt t = (tagRepo t, lower $ tagRepoOwner t)
-
-keym mr = (mrRepo mr, lower $ mrRepoOwner mr)
+formatPaste :: Paste -> MsgContent
+formatPaste p = MsgContent $ T.concat
+    [ pasteAuthor p , " "
+    , pasteVerb p   , " “"
+    , pasteTitle p  , "” | "
+    , pasteUrl p
+    ]
 
+annCommits
+    :: BranchName
+    -> [MsgContent]
+    -> MsgContent
+    -> RepoAnnSpec
+    -> BotSession ()
 annCommits branch msgs ellip spec =
-    let chan = pAnnChannel spec
-    in  when (branch `passes` pAnnBranches spec) $
-        if pAnnAllCommits spec || length msgs <= 3
+    let chan = rasChannel spec
+    in  when (rasCommits spec && branch `passes` rasBranches spec) $
+        if rasAllCommits spec || length msgs <= 3
             then mapM_ (sendToChannel chan) msgs
             else do
                 let firstCommit = head msgs
@@ -126,73 +158,128 @@
                 sendToChannel chan ellip
                 sendToChannel chan lastCommit
 
+makeVerbal :: [Text] -> Text
 makeVerbal []     = "... ummm... I don’t know, actually. How embarrassing"
 makeVerbal [n]    = n
-makeVerbal [n, m] = n ++ " and " ++ m
-makeVerbal (n:ns) = n ++ ", " ++ makeVerbal ns
+makeVerbal [n, m] = n <> " and " <> m
+makeVerbal (n:ns) = n <> ", " <> makeVerbal ns
 
-handler (GitPushEvent (Push branch commits)) = do
-    chans <- getStateS $ stGitAnnChans . bsSettings
-    case M.lookup (keyb branch) chans of
-        Just specs ->
-            let fmt = formatCommit (branchName branch) (branchRepo branch)
-                msgs = map fmt commits
-                len = case commits of
-                    [] -> 0
-                    cs -> length $ commitAuthor $ last cs
-                ellip =
-                    formatEllipsis
-                        len
-                        (branchName branch)
-                        (branchRepo branch)
-                        (length msgs - 2)
-            in  mapM_ (annCommits (branchName branch) msgs ellip) specs
-        Nothing ->
-            liftIO $ putStrLn $
-                "Ext handler: Git push for unregistered repo: " ++
-                show (keyb branch)
-handler (GitTagEvent tag) = do
-    chans <- getStateS $ stGitAnnChans . bsSettings
-    case M.lookup (keyt tag) chans of
-        Just specs ->
-            let msg = formatTag tag
-                ann chan = sendToChannel chan msg
-            in  mapM_ (ann . pAnnChannel) specs
-        Nothing ->
-            liftIO $ putStrLn $
-                "Ext handler: Tag for unregistered repo: " ++
-                show (keyt tag)
-handler (MergeRequestEvent mr) = do
-    chans <- getStateS $ stGitAnnChans . bsSettings
-    case M.lookup (keym mr) chans of
-        Just specs ->
-            let msg = formatMR mr
-                ann chan = sendToChannel chan msg
-            in  mapM_ (ann . pAnnChannel) specs
-        Nothing ->
-            liftIO $ putStrLn $
-                "Ext handler: MR for unregistered repo: " ++
-                show (keym mr)
-handler (NewsEvent item) = do
+repoKey :: Repository -> (RepoSpace, RepoName)
+repoKey repo =
+    ( RepoSpace $ CI.mk $ repoSpace repo
+    , RepoName $ CI.mk $ repoName repo
+    )
+
+handlePO
+    :: (Text -> BotSession ())
+    -> ProjectObject a
+    -> Text
+    -> (Seq RepoAnnSpec -> BotSession ())
+    -> BotSession ()
+handlePO elog (ProjectObject repo obj) desc act = do
+    hosts <- getStateS $ stGitAnnChans . bsSettings
+    sites <- getStateS $ stDevHosts . bsSettings
+    case M.lookup (DevHost $ CI.mk $ repoHost repo) sites of
+        Nothing -> elog $ sformat
+            ( "Ext handler: "
+            % stext
+            % " for unregistered dev host: "
+            % stext
+            % " | "
+            % shown
+            )
+            desc (repoHost repo) repo
+        Just dhl -> case M.lookup dhl hosts of
+            Nothing -> elog $ sformat
+                ( "Ext handler: "
+                % stext
+                % " for unregistered dev host label section: "
+                % stext
+                % " | "
+                % shown
+                )
+                desc (unDevHostLabel dhl) repo
+            Just repos -> case M.lookup (repoKey repo) repos of
+                Nothing -> elog $ sformat
+                    ( "Ext handler: "
+                    % stext
+                    % " for unregistered repo under "
+                    % stext
+                    % ": "
+                    % stext
+                    % "/"
+                    % stext
+                    )
+                    desc (unDevHostLabel dhl) (repoSpace repo) (repoName repo)
+                Just specs -> act specs
+
+handleSimple
+    :: (Text -> BotSession ())
+    -> ProjectObject a
+    -> Text
+    -> (RepoAnnSpec -> Bool)
+    -> (ProjectObject a -> MsgContent)
+    -> BotSession ()
+handleSimple elog po desc enabled fmt =
+    handlePO elog po desc $ \ specs ->
+        let msg = fmt po
+            ann spec =
+                when (enabled spec) $ sendToChannel (rasChannel spec) msg
+        in  mapM_ ann specs
+
+handler'
+    :: (Text -> BotSession ())
+    -> (Text -> BotSession ())
+    -> ExtEvent
+    -> BotSession ()
+handler' elog _dlog (GitPushEvent po) = handlePO elog po "Push" $ \ specs ->
+    let repo = poRepo po
+        push = poObj po
+        branch = BranchName $ pushBranch push
+        reponame = RepoName $ CI.mk $ repoName repo
+        fmt = formatCommit branch reponame
+        commits = pushCommits push
+        msgs = map fmt commits
+        len = case commits of
+                [] -> 0
+                cs -> T.length $ commitAuthor $ last cs
+        ellip =
+            formatEllipsis
+                len
+                branch
+                reponame
+                (length msgs - 2)
+    in  mapM_ (annCommits branch msgs ellip) specs
+handler' elog _dlog (GitTagEvent po) =
+    handleSimple elog po "Tag" rasCommits formatTag
+handler' elog _dlog (MergeRequestEvent po) =
+    handleSimple elog po "MR" rasMergeRequests formatMR
+handler' elog _dlog (IssueEvent po) =
+    handleSimple elog po "Issue" rasIssues formatIssue
+handler' elog _dlog (NoteEvent po) =
+    handleSimple elog po "Note" rasNotes formatNote
+handler' elog _dlog (NewsEvent item) = do
     feeds <- getStateS $ stWatchedFeeds . bsSettings
     let label = itemFeedLabel item
-    case M.lookup label feeds of
+    case M.lookup (FeedLabel $ CI.mk label) feeds of
         Just NewsFeed { nfAnnSpec = spec } ->
             let msg = formatNews item (nAnnFields spec)
             in  mapM_ (\ chan -> sendToChannel chan msg) (nAnnChannels spec)
-        Nothing -> liftIO $ do
-            putStrLn $ "Ext handler: Feed item with unknown label: " ++ label
-            print item
-handler (PasteEvent paste) =
-    sendToChannel (pasteChannel paste) $ formatPaste paste
-handler (WelcomeEvent nick chan) = do
+        Nothing -> do
+            elog $ "Ext handler: Feed item with unknown label: " <> label
+            elog $ T.pack $ show item
+handler' _elog _dlog (PasteEvent paste) =
+    sendToChannel (Channel $ pasteChannel paste) $ formatPaste paste
+handler' _elog _dlog (WelcomeEvent nickt chant) = do
     getTime <- askTimeGetter
     now <- liftIO $ liftM fst getTime
+    let chan = Channel chant
+        nick = Nickname nickt
     mt <- getStateS $ M.lookup chan . bsLastMsgTime
     let quiet =
             case mt of
                 Nothing -> True
-                Just t  -> diffUTCTime t now >= 60 --seconds
+                Just t  -> diffUTCTime now t >= fromIntegral welcomeDelay
     tracked <- channelIsTracked chan
     isHere <- nick `isInChannel` chan
     let assumeHere = tracked && isHere
@@ -201,13 +288,23 @@
         case M.lookup chan chans of
             Nothing -> return ()
             Just cs ->
-                sendToChannel chan $
-                printf
-                    "Welcome, %v! The channel is pretty quiet right now, so I \
-                    \thought I’d say hello. For reference, the main people \
-                    \here to ping if you have questions are %v. Also, if no \
-                    \one responds for a while, try emailing us at %v, or just \
-                    \come back later."
-                    nick
-                    (makeVerbal $ csFolks cs)
+                sendToChannel chan $ MsgContent $
+                sformat
+                    ( "Welcome, " % stext % "! The channel is pretty quiet \
+                    \right now, so I thought I’d say hello. For reference, \
+                    \the main people here to ping if you have questions are "
+                    % stext % ". Also, if no one responds for a while, try \
+                    \emailing us at " % stext % ", or just come back later."
+                    )
+                    nickt
+                    (makeVerbal $ map unNickname $ csFolks cs)
                     (csEmail cs)
+
+handler
+    :: (Text -> BotSession ())
+    -> (Text -> BotSession ())
+    -> ExtEvent
+    -> BotSession ()
+handler elog dlog event = do
+    dlog $ T.pack $ show event
+    handler' elog dlog event
diff --git a/src/FunBot/History.hs b/src/FunBot/History.hs
--- a/src/FunBot/History.hs
+++ b/src/FunBot/History.hs
@@ -1,6 +1,6 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@rel4tion.org>.
  -
  - ♡ Copying is an act of love. Please copy, reuse and share.
  -
@@ -25,86 +25,126 @@
 
 import Control.Monad (liftM, unless, when)
 import Control.Monad.IO.Class (liftIO)
-import Data.Foldable (find, mapM_)
+import Data.Foldable
+import Data.Traversable (for)
 import Data.Int (Int64)
 import Data.Monoid ((<>))
-import Data.Sequence ((|>), Seq, ViewL (..))
+import Data.Sequence ((|>), Seq, ViewL (..), ViewR (..))
+import Data.Text (Text)
+import Data.Time.Clock
+import Data.Time.Clock.POSIX
+import Formatting ((%), int)
 import FunBot.Config (quoteDir)
 import FunBot.Types
 import Network.IRC.Fun.Bot.Chat
 import Network.IRC.Fun.Bot.MsgCount
 import Network.IRC.Fun.Bot.State
-import Network.IRC.Fun.Bot.Types (HistoryLine (..))
+import Network.IRC.Fun.Bot.Types
 import Network.IRC.Fun.Color
+import Network.IRC.Fun.Types.Base
 import Prelude hiding (mapM_)
 import System.IO
 import Text.Printf (printf)
 
 import qualified Data.HashMap.Lazy as M
 import qualified Data.Sequence as Q
+import qualified Data.Text as T
+import qualified Data.Text.IO as TIO
 
 findLast :: (a -> Bool) -> Seq a -> Maybe a
 findLast p s = fmap (Q.index s) $ Q.findIndexR p s
 
-formatLine :: HistoryLine -> String
+formatLine :: HistoryLine -> MsgContent
 formatLine hl =
-    let time = Purple #> Pure (hlTime hl ++ " UTC")
+    let time = Purple #> plain (hlTime hl <> " UTC")
+        nick = unNickname $ hlNick hl
         sender =
             if hlAction hl
                     then
-                        "* " <> Green #> Pure (hlNick hl)
+                        "* " <> Green #> plain nick
                     else
-                        Gray #> "<" <> Green #> Pure (hlNick hl) <> Gray #> ">"
-        content = Pure $ hlMessage hl
-    in  encode $ time <> " " <> sender <> " " <> content
+                        Gray #> "<" <> Green #> plain nick <> Gray #> ">"
+        content = plain $ unMsgContent $ hlMessage hl
+    in  MsgContent $ encode $ time <> " " <> sender <> " " <> content
 
+timeParted :: Channel -> Nickname -> BotSession (Maybe UTCTime)
+timeParted chan nick = fmap f $ getCountLog chan
+    where
+    f q = case Q.viewr q of
+            EmptyR                  -> Nothing
+            r :> (MsgCountPart n t) -> if n == nick then Just t else f r
+            r :> _                  -> f r
+
+urlQuery :: UTCTime -> Text
+urlQuery t =
+    let stamp = floor $ utcTimeToPOSIXSeconds t
+    in  format ("?timestamp=" % int % "#t" % int) stamp stamp
+
+browseUrl :: Channel -> Nickname -> BotSession (Maybe Text)
+browseUrl chan nick = do
+    mcs <- getStateS $ M.lookup chan . stChannels . bsSettings
+    let mbrowse = mcs >>= csBrowse
+        getQuery = fmap urlQuery <$> timeParted chan nick
+    for mbrowse $ \ b -> maybe b (b <>) <$> getQuery
+
 -- | Record someone's last message as a quote.
-quote :: String --
-      -> String --
-      -> BotSession ()
+quote :: Channel -> Nickname -> BotSession ()
 quote chan nick = do
     history <- getHistory
     let sameNick hl = hlNick hl == nick
     case M.lookup chan history >>= findLast sameNick of
         Just hl -> do
-            let file = quoteDir ++ "/server." ++ chan
+            let file = quoteDir ++ "/server." ++ (T.unpack $ unChannel chan)
             liftIO $ withFile file AppendMode $ \ h -> do
                 hPutChar h '\n'
-                hPutStrLn h $ hlTime hl
-                hPutStrLn h nick
-                hPutStrLn h $ hlMessage hl
-            sendToChannel chan "Quote logged."
-        Nothing -> sendToChannel chan "No recent messages by that user."
+                TIO.hPutStrLn h $ hlTime hl
+                TIO.hPutStrLn h $ unNickname nick
+                TIO.hPutStrLn h $ unMsgContent $ hlMessage hl
+            sendToChannel chan $ MsgContent "Quote logged."
+        Nothing ->
+            sendToChannel chan $ MsgContent "No recent messages by that user."
 
 -- Send last channel messages to a user, for a specific channel.
-reportHistory :: String -- ^ User nickname
-              -> String -- ^ Channel
-              -> Int    -- ^ Maximal number of messages to send
-              -> BotSession ()
-reportHistory recip chan maxlen = do
+reportHistory
+    :: Nickname -- ^ User nickname
+    -> Channel  -- ^ Channel
+    -> Int      -- ^ Maximal number of messages to send
+    -> Bool     -- ^ Whether send notice instead of regular privmsg
+    -> BotSession ()
+reportHistory recip chan maxlen notice = do
+    let send = sendToUser' notice
     c <- chanIsCounted chan
     missed <-
         if c
             then do
                 res <- msgsSinceParted recip chan
+                murl <- browseUrl chan recip
                 case res of
                     Left n  -> do
-                        sendToUser recip $
-                            printf
-                                "You missed at least %v messages in %v."
-                                n
-                                chan
+                        send recip $
+                            formatMsg
+                                ("You missed at least " % int % " messages in "
+                                % channel % "."
+                                )
+                                n chan
+                        for_ murl $ send recip . MsgContent
                         return Nothing
                     Right n -> do
-                        sendToUser recip $
-                            if n == 0
-                                then printf
-                                        "You didn't miss any messages in %v."
-                                        chan
-                                else printf
-                                        "You missed %v messages in %v."
-                                        n
+                        if n == 0
+                            then send recip $
+                                    formatMsg
+                                        ("You didn't miss any messages in "
+                                        % channel % "."
+                                        )
                                         chan
+                            else do
+                                send recip $
+                                    formatMsg
+                                        ("You missed " % int % " messages in "
+                                        % channel % "."
+                                        )
+                                        n chan
+                                for_ murl $ send recip . MsgContent
                         return $ Just n
             else return Nothing
     mhls <- liftM (M.lookup chan) getHistory
@@ -116,30 +156,38 @@
                 hls = Q.drop (lAll - maxlen') hlsAll
                 l = Q.length hls
             unless (Q.null hls) $ do
-                sendToUser recip $ printf "Last %v messages in %v:" l chan
-                mapM_ (sendToUser recip . formatLine) hls
+                send recip $
+                    formatMsg
+                        ("Last " % int % " messages in " % channel % ":")
+                        l chan
+                mapM_ (send recip . formatLine) hls
 
 -- Send recent channel messages to a user, for a specific channel.
-reportHistory' :: String -- ^ User nickname
-               -> String -- ^ Channel
-               -> Int64  -- ^ Minutes to go back in history
-               -> BotSession ()
+reportHistory'
+    :: Nickname -- ^ User nickname
+    -> Channel  -- ^ Channel
+    -> Int64    -- ^ Minutes to go back in history
+    -> BotSession ()
 reportHistory' recip chan mins = do
     mhls <- liftM (M.lookup chan) getHistory
     case mhls of
         Nothing     ->
-            sendToUser recip "No history log found for that channel."
+            sendToUser recip $ MsgContent
+                "No history log found for that channel."
         Just hlsAll -> do
             now <- getMinutes
             let recent hl = now - hlMinute hl <= mins
                 hls = Q.takeWhileR recent hlsAll
             if  Q.null hls
                     then sendToUser recip $
-                            printf "No messages in last %v minutes." mins
+                            formatMsg
+                                ("No messages in last " % int % " minutes.")
+                                mins
                     else do
                         sendToUser recip $
-                            printf
-                                "Messages I remember from last %v minutes in \
-                                \%v:"
+                            formatMsg
+                                ("Messages I remember from last " % int
+                                % " minutes in " % channel % ":"
+                                )
                                 mins chan
                         mapM_ (sendToUser recip . formatLine) hls
diff --git a/src/FunBot/IrcHandlers.hs b/src/FunBot/IrcHandlers.hs
--- a/src/FunBot/IrcHandlers.hs
+++ b/src/FunBot/IrcHandlers.hs
@@ -1,6 +1,6 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
  -
  - ♡ Copying is an act of love. Please copy, reuse and share.
  -
@@ -29,96 +29,113 @@
 import Control.Concurrent (forkIO, threadDelay)
 import Control.Concurrent.Chan (writeChan)
 import Control.Exception (catch)
-import Control.Monad (liftM, when, void)
+import Control.Monad (liftM, unless, when, void)
 import Control.Monad.IO.Class (liftIO)
-import Data.Char (isAlphaNum, toLower)
+import Data.Char (isAlphaNum)
 import Data.Maybe (listToMaybe, mapMaybe)
-import Data.List (isPrefixOf, stripPrefix)
+import Data.Monoid ((<>))
+import Data.Text (Text)
 import Data.Time.Units
+import FunBot.Config (welcomeDelay)
 import FunBot.ExtEvents (ExtEvent (WelcomeEvent))
 import FunBot.History (reportHistory)
 import FunBot.KnownNicks
 import FunBot.Memos (reportMemos, reportMemosAll)
 import FunBot.Types
 import FunBot.UserOptions (getUserHistoryOpts)
+import FunBot.Util (unsnoc)
 import Network.HTTP.Client
 import Network.HTTP.Client.TLS (tlsManagerSettings)
 import Network.IRC.Fun.Bot.Chat (sendToChannel)
 import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Types.Base
 import Text.HTML.TagSoup
 
 import qualified Data.ByteString as B
 import qualified Data.ByteString.Lazy.UTF8 as BU
 import qualified Data.HashMap.Lazy as M
-
-helloWords :: [String]
-helloWords = ["hello", "hi", "hey", "yo"]
+import qualified Data.Text as T
 
-waveWordsL :: [String]
+waveWordsL :: [Text]
 waveWordsL = ["\\o", "\\O", "\\0"]
 
-waveWordsR :: [String]
+waveWordsR :: [Text]
 waveWordsR = ["o/", "O/", "0/"]
 
-lastChars :: String
+lastChars :: [Char]
 lastChars = ".!?"
 
-isHello :: String -> Bool
-isHello s =
-    let s' = map toLower s
-    in     null s
-        || s' `elem` helloWords
-        || init s' `elem` helloWords && last s' `elem` lastChars
+isWord :: [Text] -> Maybe [Char] -> Text -> Bool
+isWord ws mcs w =
+    case listToMaybe $ mapMaybe (flip T.stripPrefix $ T.toLower w) ws of
+        Nothing -> False
+        Just r  ->
+            case T.uncons $ T.stripStart r of
+                Nothing     -> True
+                Just (c, _) -> maybe True (c `elem`) mcs
 
-isPing :: String -> Bool
-isPing s =
-    case stripPrefix "ping" $ map toLower s of
-        Just []  -> True
-        Just [c] -> c `elem` lastChars
-        _        -> False
+isHello :: Text -> Bool
+isHello = isWord ["hello", "hi", "hey", "yo"] (Just lastChars)
 
-isThanks :: String -> Bool
-isThanks s =
-    let slow = map toLower s
-    in  case (stripPrefix "thanks" slow, stripPrefix "thank you" slow) of
-            (Nothing, Nothing) -> False
-            _                  -> True
+isPing :: Text -> Bool
+isPing = isWord ["ping"] (Just lastChars)
 
-sayHello chan nick msg
-    | isHello msg           = sendToChannel chan $ "Hello, " ++ nick
-    | isPing msg            = sendToChannel chan $ nick ++ ", pong"
-    | isThanks msg          = sendToChannel chan $ nick ++ ", you’re welcome!"
-    | msg `elem` waveWordsL = sendToChannel chan $ nick ++ ": o/"
-    | msg `elem` waveWordsR = sendToChannel chan $ nick ++ ": \\o"
+isThanks :: Text -> Bool
+isThanks = isWord ["thanks", "thank you"] Nothing
+
+sayHello :: Channel -> Nickname -> MsgContent -> BotSession ()
+sayHello chan (Nickname nick) (MsgContent msg)
+    | isHello msg           = sendToChannel chan $ MsgContent $ "Hello, " <> nick
+    | isPing msg            = sendToChannel chan $ MsgContent $ nick <> ", pong"
+    | isThanks msg          = sendToChannel chan $ MsgContent $ nick <> ", you’re welcome!"
+    | msg `elem` waveWordsL = sendToChannel chan $ MsgContent $ nick <> ": o/"
+    | msg `elem` waveWordsR = sendToChannel chan $ MsgContent $ nick <> ": \\o"
     | otherwise             = return ()
 
+recordTime :: Channel -> BotSession ()
 recordTime chan = do
     getTime <- askTimeGetter
-    now <- liftIO $ liftM fst getTime
+    now <- liftIO $ fmap fst getTime
     let update = M.insert chan now
     modifyState $ \ s -> s { bsLastMsgTime = update $ bsLastMsgTime s }
 
-handleBotMsg chan nick msg full = do
+handleBotMsg
+    :: Channel
+    -> Nickname
+    -> MsgContent
+    -> MsgContent
+    -> BotSession ()
+handleBotMsg chan nick msg _full = do
     sayHello chan nick msg
     recordTime chan
 
+isUnderscored :: Nickname -> Channel -> BotSession Bool
+isUnderscored nick chan =
+    case unsnoc $ unNickname nick of
+        Just (t, '_') -> nickIsKnown (Nickname t) chan
+        _             -> return False
+
+handleJoin :: Channel -> Nickname -> BotSession ()
 handleJoin chan nick = do
     sel <- channelSelected chan
     when sel $ do
         new <- rememberNick' nick chan
         when new $ do
             saveKnownNicks
-            mcs <- getStateS $ M.lookup chan . stChannels . bsSettings
-            let welcome = maybe False csWelcome mcs
-            when welcome $ do
-                q <- askEnvS loopbackQueue
-                liftIO $ void $ forkIO $ do
-                    threadDelay $ fromInteger $ toMicroseconds (1 :: Minute)
-                    writeChan q $ WelcomeEvent nick chan
+            ud <- isUnderscored nick chan
+            unless ud $ do
+                mcs <- getStateS $ M.lookup chan . stChannels . bsSettings
+                let welcome = maybe False csWelcome mcs
+                when welcome $ do
+                    q <- askEnvS loopbackQueue
+                    liftIO $ void $ forkIO $ do
+                        threadDelay $ welcomeDelay * 1000 * 1000
+                        writeChan q $ WelcomeEvent (unNickname nick) (unChannel chan)
     hd <- getUserHistoryOpts nick chan
-    when (hdEnabled hd) $ reportHistory nick chan (hdMaxLines hd)
+    when (hdEnabled hd) $ reportHistory nick chan (hdMaxLines hd) True
     reportMemos nick chan
 
+goodHost :: B.ByteString -> Bool
 goodHost h =
     let n = B.length h
         suffix6 = B.drop (n - 6) h
@@ -127,20 +144,22 @@
         isCom = suffix4 == ".com"
     in  not $ isCom || isCo
 
+findTitle :: String -> Maybe Text
 findTitle page =
     let tags = parseTags page
         from = drop 1 $ dropWhile (not . isTagOpenName "title") tags
         range = takeWhile (not . isTagCloseName "title") from
         text = unwords $ words $ innerText range
-    in  if null text then Nothing else Just text
+    in  if null text then Nothing else Just $ T.pack text
 
-sayTitle chan msg = when ("http" `isPrefixOf` msg) $ do
+sayTitle :: Channel -> MsgContent -> BotSession ()
+sayTitle chan (MsgContent msg) = when ("http" `T.isPrefixOf` msg) $ do
     chans <- getStateS $ stChannels . bsSettings
     let say = maybe True csSayTitles $ M.lookup chan chans
     when say $ do
         manager <- liftIO $ newManager tlsManagerSettings
         let action = do
-                request <- parseUrl msg
+                request <- parseUrl $ T.unpack msg
                 let h = host request
                 if goodHost h
                     then do
@@ -152,32 +171,55 @@
             getTitle = action `catch` handler
         etitle <- liftIO getTitle
         case etitle of
-            Right (Just title) -> sendToChannel chan $ '“' : title ++ "”"
+            Right (Just title) -> sendToChannel chan $ MsgContent $ "“" <> title <> "”"
             _                  -> return ()
 
-search _   ""   = Nothing
-search msg pref = f msg False
+-- | Search for a shortcut prefix in the string, and return the word following
+-- it (i.e. the argument) if found. Requirements and conditions:
+--
+-- * The prefix must come after a non-alphanum char (or beginning of message)
+-- * The argument is the longest alphanum sequence following the prefix
+--
+-- (1) see if we have the prefix here
+-- (2) if yes, take until non-alphanum and DONE
+-- (3) if not, check if the first char is alphanum
+-- (4) if yes, drop it and then all alphanum and repeat
+-- (5) if not, drop it and repeat
+search
+    :: Text -- ^ Search in this
+    -> Text -- ^ Search for this
+    -> Maybe Text
+search msg pref =
+    if T.null pref
+        then Nothing
+        else f msg
     where
     skip = isAlphaNum
     pick = isAlphaNum
-    f ""       _     = Nothing
-    f (c:cs)   True  = f cs (skip c)
-    f s@(c:cs) False =
-        let next = f cs (skip c)
-        in  case stripPrefix pref s of
-                Nothing -> next
-                Just r  ->
-                    case span pick r of
-                        ("", _)     -> next
-                        (p,  "")    -> Just p
-                        (p,  (d:_)) ->
-                            if skip d
-                                then next
-                                else Just p
+    once t =
+        case T.stripPrefix pref t of
+            Just r  ->
+                let a = T.takeWhile pick r
+                in  if T.null a
+                        then Nothing
+                        else Just a
+            Nothing -> Nothing
+    f t =
+        case once t of
+            succ@(Just _) -> succ
+            Nothing       ->
+                case T.uncons t of
+                    Nothing     -> Nothing
+                    Just (c, r) ->
+                        if skip c
+                            then f $ T.dropWhile skip r
+                            else f r
 
-format cut s = shPrefix cut ++ s ++ " | " ++ shBefore cut ++ s ++ shAfter cut
+format :: Shortcut -> Text -> Text
+format cut t = T.concat [shPrefix cut, t, " | ", shBefore cut, t, shAfter cut]
 
-sayTicket chan msg = do
+sayTicket :: Channel -> MsgContent -> BotSession ()
+sayTicket chan (MsgContent msg) = do
     allCuts <- getStateS $ stShortcuts . bsSettings
     let applies cut = chan `elem` shChannels cut
         cuts = M.elems $ M.filter applies allCuts
@@ -186,19 +228,27 @@
         first = listToMaybe results
     case first of
         Nothing       -> return ()
-        Just (cut, s) -> sendToChannel chan $ format cut s
+        Just (cut, s) -> sendToChannel chan $ MsgContent $ format cut s
 
+handleMsg :: Channel -> Nickname -> MsgContent -> Bool -> BotSession ()
 handleMsg chan nick msg _mention = do
     sayTitle chan msg
     sayTicket chan msg
     recordTime chan
 
+handleAction :: Channel -> Nickname -> MsgContent -> Bool -> BotSession ()
 handleAction chan nick msg _mention = do
     sayTicket chan msg
     recordTime chan
 
+handleNickChange :: Nickname -> Nickname -> BotSession ()
 handleNickChange _old new = reportMemosAll new
 
+handleNames
+    :: Channel
+    -> ChannelPrivacy
+    -> [(Privilege, Nickname)]
+    -> BotSession ()
 handleNames chan _priv pairs = do
     sel <- channelSelected chan
     when sel $ do
diff --git a/src/FunBot/KnownNicks.hs b/src/FunBot/KnownNicks.hs
--- a/src/FunBot/KnownNicks.hs
+++ b/src/FunBot/KnownNicks.hs
@@ -1,6 +1,6 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
+ - Written in 2015 by fr33domlover <fr33domlover@riseup.net>.
  -
  - ♡ Copying is an act of love. Please copy, reuse and share.
  -
@@ -13,6 +13,8 @@
  - <http://creativecommons.org/publicdomain/zero/1.0/>.
  -}
 
+{-# LANGUAGE GeneralizedNewtypeDeriving, StandaloneDeriving #-}
+
 module FunBot.KnownNicks
     ( rememberNick
     , rememberNick'
@@ -25,17 +27,19 @@
 where
 
 import Control.Monad.IO.Class (liftIO)
+import Data.Aeson (FromJSON, ToJSON)
 import Data.JsonState
 import FunBot.Config (stateSaveInterval, configuration, nicksFilename)
 import FunBot.Types
 import Network.IRC.Fun.Bot.State
 import Network.IRC.Fun.Bot.Types (Config (cfgStateRepo))
+import Network.IRC.Fun.Types.Base (Channel, Nickname (..))
 
 import qualified Data.HashMap.Lazy as M
 import qualified Data.HashSet as S
 
 -- | Consider this nick known in the given channel from now on.
-rememberNick :: String -> String -> BotSession ()
+rememberNick :: Nickname -> Channel -> BotSession ()
 rememberNick nick chan = do
     chans <- getStateS bsKnownNicks
     let nicks = M.lookup chan chans
@@ -45,7 +49,7 @@
 
 -- | A variant of 'rememberNick' which returns whether the nick was indeed new.
 -- If not, it means the nick was already known.
-rememberNick' :: String -> String -> BotSession Bool
+rememberNick' :: Nickname -> Channel -> BotSession Bool
 rememberNick' nick chan = do
     chans <- getStateS bsKnownNicks
     let ins ns = do
@@ -60,7 +64,7 @@
                 else ins $ S.insert nick nicks
 
 -- | Consider these nicks known in the given channel from now on.
-rememberNicks :: [String] -> String -> BotSession ()
+rememberNicks :: [Nickname] -> Channel -> BotSession ()
 rememberNicks nicks chan = do
     chans <- getStateS bsKnownNicks
     let new = S.fromList nicks
@@ -70,24 +74,27 @@
     modifyState $ \ s -> s { bsKnownNicks = chans' }
 
 -- | Check whether the given nick is known in the given channel.
-nickIsKnown :: String -> String -> BotSession Bool
+nickIsKnown :: Nickname -> Channel -> BotSession Bool
 nickIsKnown nick chan = do
     chans <- getStateS bsKnownNicks
     return $ case M.lookup chan chans of
         Nothing    -> False
         Just nicks -> nick `S.member` nicks
 
+deriving instance FromJSON Nickname
+deriving instance ToJSON Nickname
+
 -- | Load known nicks data from JSON file.
-loadKnownNicks :: IO (M.HashMap String (S.HashSet String))
+loadKnownNicks :: IO (M.HashMap Channel (S.HashSet Nickname))
 loadKnownNicks = do
     r <- loadState $ stateFilePath nicksFilename (cfgStateRepo configuration)
     case r of
         Left (False, e) -> error $ "Failed to read known nicks file: " ++ e
         Left (True, e)  -> error $ "Failed to parse known nicks file: " ++ e
-        Right s         -> return s
+        Right hm        -> return hm
 
 -- | Create a safe async known nicks data saver action.
-mkSaveKnownNicks :: IO (M.HashMap String (S.HashSet String) -> IO ())
+mkSaveKnownNicks :: IO (M.HashMap Channel (S.HashSet Nickname) -> IO ())
 mkSaveKnownNicks =
     mkSaveStateChoose
         stateSaveInterval
diff --git a/src/FunBot/Locations.hs b/src/FunBot/Locations.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Locations.hs
@@ -0,0 +1,42 @@
+{- This file is part of funbot.
+ -
+ - Written in 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+module FunBot.Locations
+    ( lookupLocal
+    , lookupGlobal
+    , lookupBoth
+    )
+where
+
+import FunBot.Types
+import Network.IRC.Fun.Bot.State (getStateS)
+import Network.IRC.Fun.Types (Channel)
+
+import qualified Data.HashMap.Lazy as M (lookup)
+
+lookupLocal :: Channel -> LocationLabel -> BotSession (Maybe Location)
+lookupLocal chan label = do
+    chans <- getStateS $ stChannels . bsSettings
+    return $ M.lookup chan chans >>= M.lookup label . csLocations
+
+lookupGlobal :: LocationLabel -> BotSession (Maybe Location)
+lookupGlobal label = do
+    locs <- getStateS $ stLocations . bsSettings
+    return $ M.lookup label locs
+
+lookupBoth :: Channel -> LocationLabel -> BotSession (Maybe Location)
+lookupBoth chan label = do
+    mloc <- lookupLocal chan label
+    maybe (lookupGlobal label) (return . Just) mloc
diff --git a/src/FunBot/Memos.hs b/src/FunBot/Memos.hs
--- a/src/FunBot/Memos.hs
+++ b/src/FunBot/Memos.hs
@@ -1,6 +1,6 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
  -
  - ♡ Copying is an act of love. Please copy, reuse and share.
  -
@@ -29,13 +29,15 @@
 import Control.Monad (liftM, mzero, unless)
 import Control.Monad.IO.Class (liftIO)
 import Data.Aeson hiding (encode)
-import qualified Data.HashMap.Lazy as M
+import Data.Aeson.Types (typeMismatch)
 import Data.JsonState
 import Data.List (partition)
 import Data.Maybe (isJust)
 import Data.Monoid ((<>))
 import Data.Time.Units (Second)
+import Formatting hiding (text)
 import FunBot.Config (stateSaveInterval, configuration, memosFilename)
+import FunBot.Settings.Instances
 import FunBot.Types
 import FunBot.Util ((!?), getTimeStr)
 import Network.IRC.Fun.Bot.Chat (sendToChannel, sendToUser)
@@ -43,28 +45,31 @@
 import Network.IRC.Fun.Bot.State
 import Network.IRC.Fun.Bot.Types (Config (cfgStateRepo))
 import Network.IRC.Fun.Color
-import Text.Printf (printf)
+import Network.IRC.Fun.Types.Base
 
+import qualified Data.HashMap.Lazy as M
+import qualified Data.Text as T
+
 -------------------------------------------------------------------------------
 -- Utilities
 -------------------------------------------------------------------------------
 
-getMemos :: BotSession (M.HashMap String [Memo])
+getMemos :: BotSession (M.HashMap Nickname [Memo])
 getMemos = getStateS bsMemos
 
-putMemos :: M.HashMap String [Memo] -> BotSession ()
+putMemos :: M.HashMap Nickname [Memo] -> BotSession ()
 putMemos ms = modifyState $ \ s -> s { bsMemos = ms }
 
-modifyMemos :: (M.HashMap String [Memo] -> M.HashMap String [Memo])
+modifyMemos :: (M.HashMap Nickname [Memo] -> M.HashMap Nickname [Memo])
             -> BotSession ()
 modifyMemos f = modifyState $ \ s -> s { bsMemos = f $ bsMemos s }
 
 -- | Get a list of the memos saved for a user, in the order they were sent.
-getUserMemos :: String -- ^ User nickname
+getUserMemos :: Nickname
              -> BotSession [Memo]
-getUserMemos recip = liftM (M.lookupDefault [] recip) getMemos
+getUserMemos recip = fmap (M.lookupDefault [] recip) getMemos
 
-insertMemo :: String -> Memo -> BotSession ()
+insertMemo :: Nickname -> Memo -> BotSession ()
 insertMemo recip memo = do
     ms <- getMemos
     let oldList = M.lookupDefault [] recip ms
@@ -73,94 +78,117 @@
 
 -- | Set (override) a user's memo list to the given list, discarding the memos
 -- previously stored there.
-setUserMemos :: String -- ^ Recipient nickname
-             -> [Memo] -- ^ New memo list to store
-             -> BotSession ()
+setUserMemos :: Nickname -> [Memo] -> BotSession ()
 setUserMemos recip memos =
     modifyMemos $ if null memos then M.delete recip else M.insert recip memos
 
 -- | Delete all memos for a given recipient, if any exist.
-deleteUserMemos :: String -- ^ Recipient nickname
-                -> BotSession ()
+deleteUserMemos :: Nickname -> BotSession ()
 deleteUserMemos recip = modifyMemos $ M.delete recip
 
 -- | Prepare an IRC message which displays a memo.
-formatMemo :: Maybe String -- ^ Optional recipient nickname to mention
-           -> Int          -- ^ Memo index to display
-           -> Memo         -- ^ Memo to format
-           -> String
+formatMemo :: Maybe Nickname -- ^ Optional recipient nickname to mention
+           -> Int               -- ^ Memo index to display
+           -> Memo              -- ^ Memo to format
+           -> MsgContent
 formatMemo (Just recip) _idx memo =
-    printf "%v, %v said in %v UTC:\n\"%v\""
-        recip
-        (memoSender memo)
+    MsgContent $ sformat
+        ( stext
+        % ", "
+        % stext
+        % " said in "
+        % stext
+        % " UTC:\n“"
+        % stext
+        % "”"
+        )
+        (unNickname recip)
+        (unNickname $ memoSender memo)
         (memoTime memo)
-        (memoContent memo)
+        (unMsgContent $ memoContent memo)
 formatMemo Nothing idx memo =
-    let n = Maroon #> ("[" <> Pure (show idx) <> "]")
-        time = Purple #> Pure (memoTime memo ++ " UTC")
-        sender = Gray #> "<" <> Green #> Pure (memoSender memo) <> Gray #> ">"
-        content = Pure $ memoContent memo
-    in  encode $ n <> " " <> time <> " " <> sender <> " " <> content
+    let n = Maroon #> plain (sformat ("[" % int % "]") idx)
+        time = Purple #> plain (memoTime memo <> " UTC")
+        sender = Gray #> "<" <> Green #> plain (unNickname $ memoSender memo) <> Gray #> ">"
+        content = plain $ unMsgContent $ memoContent memo
+    in  MsgContent $
+        encode $ n <> " " <> time <> " " <> sender <> " " <> content
 
 -- | Send a memo to its destination, nicely formatted.
-sendMemo :: String -- ^ Recipient nickname
-         -> Int    -- ^ Memo index number for display (i.e. 1-based)
-         -> Memo   -- ^ Memo to display on IRC
-         -> BotSession ()
+sendMemo
+    :: Nickname -- ^ Recipient nickname
+    -> Int         -- ^ Memo index number for display (i.e. 1-based)
+    -> Memo        -- ^ Memo to display on IRC
+    -> BotSession ()
 sendMemo recip idx memo =
     case memoSendIn memo of
         Just chan -> sendToChannel chan $ formatMemo (Just recip) idx memo
         Nothing   -> sendToUser recip $ formatMemo Nothing idx memo
 
 -- | Send a memo to its destination, nicely formatted.
-sendMemoList :: String -- ^ Recipient nickname
-             -> Int    -- ^ First memo's index number for display
-             -> [Memo] -- ^ Memos to display on IRC
-             -> BotSession ()
+sendMemoList
+    :: Nickname -- ^ Recipient nickname
+    -> Int         -- ^ First memo's index number for display
+    -> [Memo]      -- ^ Memos to display on IRC
+    -> BotSession ()
 sendMemoList recip idx ms =
     let send (i, m) = sendMemo recip i m
     in  mapM_ send $ zip [idx..] ms
 
 -- | An instant memo response into the source channel or in PM.
-sendInstant :: String       -- ^ Sender nickname
-            -> Maybe String -- ^ Source channel
-            -> String       -- ^ Recipient nickname
-            -> String       -- ^ Message
-            -> BotSession ()
+sendInstant
+    :: Nickname      -- ^ Sender nickname
+    -> Maybe Channel -- ^ Source channel
+    -> Nickname      -- ^ Recipient nickname
+    -> MsgContent    -- ^ Message
+    -> BotSession ()
 sendInstant sender mchan recip content =
     case mchan of
         Just chan -> sendToChannel chan msg
         Nothing   -> sendToUser recip msg
     where
-    msg = printf "%v, %v says: %v" recip sender content
+    msg = MsgContent $
+        unNickname recip  <>
+        ", "              <>
+        unNickname sender <>
+        " says: "         <>
+        unMsgContent content
 
 -- | Report to sender than their memo has been saved.
-confirm :: String       -- ^ Sender nickname
-        -> Maybe String -- ^ Whether sent 'Just' in channel or in PM.
-        -> String       -- ^ Recipient nickname
-        -> BotSession ()
+confirm
+    :: Nickname       -- ^ Sender nickname
+    -> Maybe Channel  -- ^ Whether sent 'Just' in channel or in PM.
+    -> Nickname       -- ^ Recipient nickname
+    -> BotSession ()
 confirm sender (Just chan) recip = do
-    sendToChannel chan $
-        printf "%v, your memo for %v has been saved." sender recip
+    sendToChannel chan $ MsgContent $ sformat
+        ( stext
+        % ", your memo for "
+        % stext
+        % " has been saved."
+        )
+        (unNickname sender)
+        (unNickname recip)
     t <- channelIsTracked chan
-    unless t $ sendToChannel chan
+    unless t $ sendToChannel chan $ MsgContent
         "Note that tracking of user joins and quits for this channel is \
         \currently disabled in bot settings."
 confirm sender Nothing recip =
-    sendToUser sender $
-    printf "Your memo for %v has been saved." recip
+    sendToUser sender $ MsgContent $
+        "Your memo for " <> unNickname recip <> " has been saved."
 
 -------------------------------------------------------------------------------
 -- Operations
 -------------------------------------------------------------------------------
 
 -- | Record a new memo for a given user.
-addMemo :: String       -- ^ Sender nickname
-        -> Maybe String -- ^ Whether received in 'Just' a channel, or in PM
-        -> Maybe String -- ^ Whether to send in 'Just' a channel, or in PM
-        -> String       -- ^ Recipient nickname
-        -> String       -- ^ Memo content
-        -> BotSession ()
+addMemo
+    :: Nickname      -- ^ Sender nickname
+    -> Maybe Channel -- ^ Whether received in 'Just' a channel, or in PM
+    -> Maybe Channel -- ^ Whether to send in 'Just' a channel, or in PM
+    -> Nickname      -- ^ Recipient nickname
+    -> MsgContent    -- ^ Memo content
+    -> BotSession ()
 addMemo sender recv send recip content = do
     time <- getTimeStr
     let memo = Memo
@@ -175,8 +203,8 @@
 -- | Send a memo with the given index if exists. Return 'Nothing' on success,
 -- or 'Just' the number of saved memos for the nickname on failure (invalid
 -- index).
-sendOneMemo :: String -- ^ Recipient nickname
-            -> Int    -- ^ Memo number, 0-based
+sendOneMemo :: Nickname -- ^ Recipient nickname
+            -> Int         -- ^ Memo number, 0-based
             -> BotSession (Maybe Int)
 sendOneMemo recip idx = do
     ms <- getMemos
@@ -189,9 +217,10 @@
 -- | Delete a memo for a given recipient with the given index (position in the
 -- memo list). On success, return 'Nothing'. On error, return 'Just' the number
 -- of saved memos the receipient has.
-deleteOneMemo :: String -- ^ Recipient nickname
-              -> Int    -- ^ Memo index number, 0-based
-              -> BotSession (Maybe Int)
+deleteOneMemo
+    :: Nickname -- ^ Recipient nickname
+    -> Int         -- ^ Memo index number, 0-based
+    -> BotSession (Maybe Int)
 deleteOneMemo recip idx = do
     ms <- getMemos
     case M.lookup recip ms of
@@ -214,13 +243,19 @@
 -- If user is online in same channel, send instantly to channel.
 -- If user is online in another channel, send in PM (and report to sender).
 -- If user not online, save memo and report to sender.
-submitMemo :: String       -- ^ Sender nickname
-           -> Maybe String -- ^ Whether sent in 'Just' a channel, or in PM
-           -> String       -- ^ Recipient nickname
-           -> Bool         -- ^ Whether to always send memo privately (True) or
-                           --   the same as source (False)
-           -> String       -- ^ Memo content
-           -> BotSession ()
+submitMemo
+    :: Nickname
+    -- ^ Sender nickname
+    -> Maybe Channel
+    -- ^ Whether sent in 'Just' a channel, or in PM
+    -> Nickname
+    -- ^ Recipient nickname
+    -> Bool
+    -- ^ Whether to always send memo privately (True) or the same as source
+    -- (False)
+    -> MsgContent
+    -- ^ Memo content
+    -> BotSession ()
 submitMemo sender source recip private content = do
     let send = if private then Nothing else source
         instantToChan =
@@ -250,9 +285,10 @@
         unless succ2 keepForLater
 
 -- Send user memos. For a specific joined channel, or for all channels.
-reportMemos' :: String       -- ^ User nickname
-             -> Maybe String -- ^ The channel the user joined
-             -> BotSession ()
+reportMemos'
+    :: Nickname       -- ^ User nickname
+    -> Maybe Channel -- ^ The channel the user joined
+    -> BotSession ()
 reportMemos' recip mchan = do
     ms <- getUserMemos recip
     let (msChan, msPriv) = partition (isJust . memoSendIn) ms
@@ -268,7 +304,8 @@
             return $ partition (isThese . memoSendIn) msChan
     unless (null msPriv) $ do
         let n = length msPriv
-        sendToUser recip $ "Hello! You have " ++ show n ++ " private memos:"
+        sendToUser recip $ MsgContent $
+            sformat ("Hello! You have " % int % " private memos:") n
         sendMemoList recip 1 msPriv
     sendMemoList recip 1 msChanSend
     unless (null msPriv && null msChanSend) $ do
@@ -277,15 +314,15 @@
 
 -- | When a user logs in, use this to send them a report of the memos saved for
 -- them, if any exist.
-reportMemos :: String -- ^ User nickname
-            -> String -- ^ The channel the user joined triggering the report
-            -> BotSession ()
+reportMemos
+    :: Nickname -- ^ User nickname
+    -> Channel -- ^ The channel the user joined triggering the report
+    -> BotSession ()
 reportMemos recip chan = reportMemos' recip (Just chan)
 
 -- | Like 'reportMemos', but reports memos to all channels in which the user is
 -- present.
-reportMemosAll :: String -- ^ User nickname
-               -> BotSession ()
+reportMemosAll :: Nickname -> BotSession ()
 reportMemosAll recip = reportMemos' recip Nothing
 
 -------------------------------------------------------------------------------
@@ -296,24 +333,22 @@
     parseJSON (Object o) =
         Memo <$>
         o .: "time" <*>
-        o .: "sender" <*>
+        (Nickname <$> o .: "sender") <*>
         o .: "recv-in" <*>
         o .: "send-in" <*>
-        o .: "content" {-<*>
-        o .: "read"-}
-    parseJSON _          = mzero
+        (MsgContent <$> o .: "content")
+    parseJSON v          = typeMismatch "Memo" v
 
 instance ToJSON Memo where
-    toJSON (Memo time sender recvIn sendIn content {-rd-}) = object
+    toJSON (Memo time sender recvIn sendIn content) = object
         [ "time"    .= time
-        , "sender"  .= sender
+        , "sender"  .= unNickname sender
         , "recv-in" .= recvIn
         , "send-in" .= sendIn
-        , "content" .= content
-        --, "read"    .= rd
+        , "content" .= unMsgContent content
         ]
 
-loadBotMemos :: IO (M.HashMap String [Memo])
+loadBotMemos :: IO (M.HashMap Nickname [Memo])
 loadBotMemos = do
     r <- loadState $ stateFilePath memosFilename (cfgStateRepo configuration)
     case r of
@@ -321,7 +356,7 @@
         Left (True, e)  -> error $ "Failed to parse memos file: " ++ e
         Right s         -> return s
 
-mkSaveBotMemos :: IO (M.HashMap String [Memo] -> IO ())
+mkSaveBotMemos :: IO (M.HashMap Nickname [Memo] -> IO ())
 mkSaveBotMemos =
     mkSaveStateChoose
         stateSaveInterval
diff --git a/src/FunBot/Puppet.hs b/src/FunBot/Puppet.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Puppet.hs
@@ -0,0 +1,210 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Puppet
+    ( puppetStart
+    , puppetPrivateStart
+    , puppetEnd
+    , puppetPrivateEnd
+    , puppetSay
+    , puppetPrivateSay
+    , puppetCheck
+    , puppetCheckChannel
+    )
+where
+
+import Control.Monad (when)
+import Data.Maybe
+import Data.Monoid ((<>))
+import Data.Foldable (traverse_)
+import Formatting ((%))
+import FunBot.Types
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Color.Format (formatMsg)
+import Network.IRC.Fun.Color.Format.Long
+import Network.IRC.Fun.Types.Base
+
+import qualified Data.HashMap.Lazy as M
+import qualified Data.HashSet as S
+
+-- | Start puppet mode in the given channel by the given nickname. Return
+-- 'Nothing' on success. Otherwise 'False' means the channel is already in
+-- puppet mode, and 'True' means it isn't but the user isn't a puppeteer for
+-- that channel.
+puppetStart :: Channel -> Nickname -> BotSession (Maybe Bool)
+puppetStart chan nick = do
+    puppet <- getStateS bsPuppet
+    if isJust $ M.lookup chan puppet
+        then return $ Just False
+        else do
+            gpts <- getStateS $ stPuppeteers . bsSettings
+            mcs <- getStateS $ M.lookup chan . stChannels . bsSettings
+            let lpts = fromMaybe S.empty $ fmap csPuppeteers mcs
+            if nick `S.member` gpts || nick `S.member` lpts
+                then do
+                    let puppet' = M.insert chan nick puppet
+                    modifyState $ \ s -> s { bsPuppet = puppet' }
+                    return Nothing
+                else return $ Just True
+
+-- | Start private puppet mode by the given nickname. Return 'Nothing' on
+-- success. Otherwise 'False' means private puppet mode is already on, and
+-- 'True' means it isn't but the user isn't a global puppeteer.
+puppetPrivateStart :: Nickname -> BotSession (Maybe Bool)
+puppetPrivateStart nick = do
+    mpteer <- getStateS bsPrivPuppet
+    if isJust mpteer
+        then return $ Just False
+        else do
+            gpts <- getStateS $ stPuppeteers . bsSettings
+            if nick `S.member` gpts
+                then do
+                    modifyState $ \ s -> s { bsPrivPuppet = Just nick }
+                    return Nothing
+                else return $ Just True
+
+-- | Stop puppet mode in a channel. Return 'Nothing' on success. Otherwise
+-- 'False' means the channel isn't in puppet mode, and 'True' means it is, but
+-- the user isn't a puppeteer there.
+--
+-- Note that any puppeteer can stop puppet mode, not necessarily the one who
+-- started it. This can be useful in case the latter forgets to stop it or gets
+-- disconnected from IRC, and then someone else can do it.
+puppetEnd :: Channel -> Nickname -> BotSession (Maybe Bool)
+puppetEnd chan nick = do
+    puppet <- getStateS bsPuppet
+    if isJust $ M.lookup chan puppet
+        then do
+            gpts <- getStateS $ stPuppeteers . bsSettings
+            mcs <- getStateS $ M.lookup chan . stChannels . bsSettings
+            let lpts = fromMaybe S.empty $ fmap csPuppeteers mcs
+            if nick `S.member` gpts || nick `S.member` lpts
+                then do
+                    let puppet' = M.delete chan puppet
+                    modifyState $ \ s -> s { bsPuppet = puppet' }
+                    return Nothing
+                else return $ Just True
+        else return $ Just False
+
+-- | Stop private puppet mode. Return 'Nothing' on success. Otherwise
+-- 'False' means private puppet mode is off, and 'True' means it's on, but
+-- the user isn't a global puppeteer.
+--
+-- Note that any global puppeteer can stop private puppet mode, not necessarily
+-- the one who started it. This can be useful in case the latter forgets to
+-- stop it or gets disconnected from IRC, and then someone else can do it.
+puppetPrivateEnd :: Nickname -> BotSession (Maybe Bool)
+puppetPrivateEnd nick = do
+    mpteer <- getStateS bsPrivPuppet
+    if isJust mpteer
+        then do
+            gpts <- getStateS $ stPuppeteers . bsSettings
+            if nick `S.member` gpts
+                then do
+                    modifyState $ \ s -> s { bsPrivPuppet = Nothing }
+                    return Nothing
+                else return $ Just True
+        else return $ Just False
+
+-- | While in puppet mode, ask the bot to send a message into the channel.
+-- Return 'Nothing' on success (i.e. the message is sent to the IRC server).
+-- Otherwise, 'False' means the channel isn't in puppet mode, and 'True' means
+-- it is, but the user isn't the one who started it.
+puppetSay
+    :: Channel
+    -> Nickname
+    -> MsgContent
+    -> Bool -- ^ Whether to reveal the message comes from the puppeteer
+    -> BotSession (Maybe Bool)
+puppetSay chan nick msg reveal = do
+    puppet <- getStateS bsPuppet
+    case M.lookup chan puppet of
+        Nothing    -> return $ Just False
+        Just pteer ->
+            if nick == pteer
+                then do
+                    let msg' =
+                            if reveal
+                                then formatMsg
+                                        ("[" % nickname % "] " % message)
+                                        nick msg
+                                else msg
+                    sendToChannel chan msg'
+                    return Nothing
+                else return $ Just True
+
+-- | While in private puppet mode, ask the bot to send a message to a user.
+-- Return 'Nothing' on success (i.e. the message is sent to the IRC server).
+-- Otherwise, 'False' means private puppet mode is off, and 'True' means
+-- it is, but the user isn't the one who started it.
+puppetPrivateSay
+    :: Nickname -- ^ Recipient
+    -> Nickname -- ^ Puppeteer
+    -> MsgContent
+    -> Bool     -- ^ Whether to reveal the message comes from the puppeteer
+    -> BotSession (Maybe Bool)
+puppetPrivateSay recip nick msg reveal = do
+    mpteer <- getStateS bsPrivPuppet
+    case mpteer of
+        Nothing    -> return $ Just False
+        Just pteer ->
+            if nick == pteer
+                then do
+                    let msg' =
+                            if reveal
+                                then formatMsg
+                                        ("[" % nickname % "] " % message)
+                                        nick msg'
+                                else msg
+                    sendToUser recip msg'
+                    return Nothing
+                else return $ Just True
+
+-- | Finish puppet mode in all channels. This can be useful for emergency etc.
+-- Return whether succeeded, i.e. whether user is a global puppeteer.
+puppetReset
+    :: Nickname -- ^ Must be a global puppeteer
+    -> Bool     -- ^ Whether to announce end of puppet mode
+    -> BotSession Bool
+puppetReset nick ann = do
+    gpteers <- getStateS $ stPuppeteers . bsSettings
+    if nick `S.member` gpteers
+        then do
+            puppetChans <- getStateS $ M.keys . bsPuppet
+            muser <- getStateS bsPrivPuppet
+            modifyState $
+                \ s -> s { bsPuppet = M.empty, bsPrivPuppet = Nothing }
+            when ann $ do
+                let msg =
+                        MsgContent $ "Puppet mode reset by " <> unNickname nick
+                traverse_ (flip sendToChannel msg) puppetChans
+                traverse_ (flip sendToUser msg) muser
+            return True
+        else return False
+
+-- | Check in which channels puppet mode is enabled, and whether private puppet
+-- mode is enabled.
+puppetCheck :: BotSession ([Channel], Bool)
+puppetCheck = do
+    chans <- getStateS $ M.keys . bsPuppet
+    priv <- getStateS $ isJust . bsPrivPuppet
+    return (chans, priv)
+
+-- | Check whether puppet mode is enabled in a given channel.
+puppetCheckChannel :: Channel -> BotSession Bool
+puppetCheckChannel chan = getStateS $ (chan `M.member`) . bsPuppet
diff --git a/src/FunBot/Settings.hs b/src/FunBot/Settings.hs
--- a/src/FunBot/Settings.hs
+++ b/src/FunBot/Settings.hs
@@ -1,1070 +1,160 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
- -
- - ♡ Copying is an act of love. Please copy, reuse and share.
- -
- - The author(s) have dedicated all copyright and related and neighboring
- - rights to this software to the public domain worldwide. This software is
- - distributed without any warranty.
- -
- - You should have received a copy of the CC0 Public Domain Dedication along
- - with this software. If not, see
- - <http://creativecommons.org/publicdomain/zero/1.0/>.
- -}
-
--- For the 'MonadSettings' instance
-{-# LANGUAGE MultiParamTypeClasses, TypeSynonymInstances, FlexibleInstances #-}
-
--- For JSON field names and irc-fun-color StyledString
-{-# LANGUAGE OverloadedStrings #-}
-
-module FunBot.Settings
-    ( respondGet'
-    , respondSet'
-    , respondReset'
-    , respondSettingsHelp
-    , initTree
-    , addPushAnnSpec
-    , deletePushAnnSpec
-    , addRepo
-    , deleteRepo
-    , addChannel
-    , addShortcut
-    , deleteShortcut
-    , addFeed
-    , deleteFeed
-    , loadBotSettings
-    , mkSaveBotSettings
-    )
-where
-
-import Control.Applicative
-import Control.Monad (liftM, mzero, unless)
-import Control.Monad.IO.Class (liftIO)
-import Data.Aeson hiding (encode)
-import Data.Bool (bool)
-import Data.Char (toLower)
-import Data.Default.Class (def)
-import Data.JsonState
-import Data.List (intercalate, intersperse, isSuffixOf)
-import Data.Maybe (catMaybes, fromMaybe)
-import Data.Monoid
-import Data.Settings.Interface
-import Data.Settings.Option
-import Data.Settings.Route
-import Data.Settings.Section (deleteSub, insertSub, memberSub)
-import Data.Settings.Types
-import Data.Time.Units (Second)
-import FunBot.Config (stateSaveInterval, configuration, settingsFilename)
-import FunBot.Types
-import FunBot.Util
-import Network.IRC.Fun.Bot.Chat
-import Network.IRC.Fun.Bot.IrcLog
-import Network.IRC.Fun.Bot.MsgCount
-import Network.IRC.Fun.Bot.Nicks
-import Network.IRC.Fun.Bot.State
-import Network.IRC.Fun.Bot.Types (Config (cfgStateRepo))
-import Network.IRC.Fun.Color
-import Web.Feed.Collect hiding (addFeed)
-
-import qualified Data.HashMap.Lazy as M
-import qualified Web.Feed.Collect as F (addFeed)
-
-instance MonadSettings BotSession Settings where
-    getSettings      = getStateS bsSettings
-
-    putSettings s    = modifyState $ \ st -> st { bsSettings = s }
-
-    modifySettings f =
-        modifyState $ \ st -> st { bsSettings = f $ bsSettings st }
-
-    getSTree         = getStateS bsSTree
-
-instance OptionValue Bool where
-    readOption s
-        | s' `elem` ["off", "false", "no",  "n", "0", "[_]"] = Just False
-        | s' `elem` ["on",  "true",  "yes", "y", "1", "[x]"] = Just True
-        | otherwise                                          = Nothing
-        where
-        s' = map toLower s
-    showOption = show
-    typeName = const "Boolean"
-
-instance OptionValue String where
-    readOption = Just
-    showOption = id
-    typeName = const "String"
-
-parseList :: String -> Maybe [String]
-parseList s  =
-    case break (== ',') s of
-        ("", _)     -> Nothing
-        (p, "")     -> Just [p]
-        (p, (c:cs)) ->
-            case parseList cs of
-                Nothing -> Nothing
-                Just ps -> Just $ p : ps
-
-instance OptionValue [String] where
-    readOption s = parseList s >>= mapM readOption
-    showOption   = intercalate "," . map showOption
-    typeName     = const "List"
-
-instance FromJSON a => FromJSON (Filter a) where
-    parseJSON (Object o) =
-        Accept <$> o .: "accept" <|>
-        Reject <$> o .: "reject"
-    parseJSON _          = mzero
-
-instance ToJSON a => ToJSON (Filter a) where
-    toJSON (Accept l) = object [ "accept" .= l ]
-    toJSON (Reject l) = object [ "reject" .= l ]
-
-instance FromJSON PushAnnSpec where
-    parseJSON (Object o) =
-        PushAnnSpec <$>
-        o .: "channel" <*>
-        o .: "branches" <*>
-        o .: "all-commits"
-    parseJSON _          = mzero
-
-instance ToJSON PushAnnSpec where
-    toJSON (PushAnnSpec chan branches allc) = object
-        [ "channel"     .= chan
-        , "branches"    .= branches
-        , "all-commits" .= allc
-        ]
-
-instance FromJSON NewsItemFields where
-    parseJSON (Object o) =
-        NewsItemFields <$>
-        o .: "show-feed-title" <*>
-        o .: "show-author" <*>
-        o .: "show-url"
-    parseJSON _          = mzero
-
-instance ToJSON NewsItemFields where
-    toJSON (NewsItemFields ftitle author url) = object
-        [ "show-feed-title" .= ftitle
-        , "show-author"     .= author
-        , "show-url"        .= url
-        ]
-
-instance FromJSON NewsAnnSpec where
-    parseJSON (Object o) =
-        NewsAnnSpec <$>
-        o .: "channels" <*>
-        o .: "fields"
-    parseJSON _          = mzero
-
-instance ToJSON NewsAnnSpec where
-    toJSON (NewsAnnSpec channels fields) = object
-        [ "channels" .= channels
-        , "fields"   .= fields
-        ]
-
-instance FromJSON NewsFeed where
-    parseJSON (Object o) =
-        NewsFeed <$>
-        o .: "url" <*>
-        o .: "active" <*>
-        o .: "ann-spec"
-    parseJSON _          = mzero
-
-instance ToJSON NewsFeed where
-    toJSON (NewsFeed url active spec) = object
-        [ "url"      .= url
-        , "active"   .= active
-        , "ann-spec" .= spec
-        ]
-
-instance FromJSON (M.HashMap (String, String) [PushAnnSpec]) where
-    parseJSON v =
-        let mkpair (s, l) =
-                case break (== '/') s of
-                    (repo, _:owner) ->
-                        if not (null repo || null owner) && '/' `notElem` owner
-                            then Just ((repo, owner), l)
-                            else Nothing
-                    _               -> Nothing
-        in  M.fromList . catMaybes . map mkpair . M.toList <$> parseJSON v
-
-instance ToJSON (M.HashMap (String, String) [PushAnnSpec]) where
-    toJSON m =
-        let unpair ((repo, owner), l) = (repo ++ '/' : owner, l)
-        in  toJSON $ M.fromList $ map unpair $ M.toList m
-
-instance FromJSON Shortcut where
-    parseJSON (Object o) =
-        Shortcut <$>
-        o .: "prefix" <*>
-        o .: "before" <*>
-        o .: "after" <*>
-        o .: "channels"
-    parseJSON _          = mzero
-
-instance ToJSON Shortcut where
-    toJSON (Shortcut prefix before after chans) = object
-        [ "prefix"   .= prefix
-        , "before"   .= before
-        , "after"    .= after
-        , "channels" .= chans
-        ]
-
-instance FromJSON ChanSettings where
-    parseJSON (Object o) =
-        ChanSettings <$>
-        o .: "say-titles" <*>
-        o .: "welcome" <*>
-        o .: "folks" <*>
-        o .: "email"
-    parseJSON _          = mzero
-
-instance ToJSON ChanSettings where
-    toJSON (ChanSettings sayTitles welcome folks email) = object
-        [ "say-titles" .= sayTitles
-        , "welcome"    .= welcome
-        , "folks"      .= folks
-        , "email"      .= email
-        ]
-
-instance FromJSON Settings where
-    parseJSON (Object o) =
-        Settings <$>
-        o .: "repos" <*>
-        o .: "feeds" <*>
-        o .: "shortcuts" <*>
-        o .: "channels"
-    parseJSON _          = mzero
-
-instance ToJSON Settings where
-    toJSON (Settings repos feeds shortcuts channels) = object
-        [ "repos"     .= repos
-        , "feeds"     .= feeds
-        , "shortcuts" .= shortcuts
-        , "channels"  .= channels
-        ]
-
--- An option whose value is held by funbot's 'Settings' and saved into its
--- settings file
-mkOptionF :: OptionValue v
-          => (Settings -> v)             -- Get
-          -> (v -> Settings -> Settings) -- Set which never fails
-          -> v                           -- Default value for reset
-          -> SettingsOption
-mkOptionF get set defval = mkOptionS get set' reset cb
-    where
-    set' v s = Just $ set v s
-    reset s = (Just defval, set defval s)
-    cb = const saveBotSettings
-
--- A variant of 'mkOptionF' which accepts a callback to run after the default
--- one.
-mkOptionF' :: OptionValue v
-           => (Settings -> v)             -- Get
-           -> (v -> Settings -> Settings) -- Set which never fails
-           -> v                           -- Default value for reset
-           -> (v -> BotSession ())        -- Additional callback
-           -> SettingsOption
-mkOptionF' get set defval cbx = mkOptionS get set' reset cb
-    where
-    set' v s = Just $ set v s
-    reset s = (Just defval, set defval s)
-    cb v = saveBotSettings >> cbx v
-
--- An option whose value is held by irc-fun-bot's 'BotState' and saved into its
--- state file
-mkOptionB :: OptionValue v
-          => BotSession v         -- Get
-          -> (v -> BotSession ()) -- Set which never fails
-          -> v                    -- Default value for reset
-          -> SettingsOption
-mkOptionB get set defval = mkOptionV get set' reset
-    where
-    setTo val = set val >> cb val
-    set' val = setTo val >> return True
-    reset = setTo defval
-    cb = const saveBotState
-
--- Create a setting section for a spec, given its position in the spec list and
--- repo/owner as matched by the web listener.
-pushAnnSpecSec :: String -> String -> Int -> SettingsTree
-pushAnnSpecSec repo owner pos = Section
-    { secOpts = M.fromList
-        [ ( "channel"
-          , mkOptionF
-                getChan
-                (\ chan s ->
-                    let chans = stGitAnnChans s
-                        oldspecs = getSpecs s
-                        oldspec = getSpec s
-                        spec = oldspec { pAnnChannel = chan }
-                        specs =
-                            fromMaybe oldspecs $ replaceMaybe oldspecs pos spec
-                    in  s   { stGitAnnChans =
-                                M.insert (repo, owner) specs chans
-                            }
-                )
-                defChan
-          )
-        , ( "branches"
-          , mkOptionF
-                getBranches
-                (\ branches s ->
-                    let chans = stGitAnnChans s
-                        oldspecs = getSpecs s
-                        oldspec = getSpec s
-                        bs = case pAnnBranches oldspec of
-                            Accept _ -> Accept branches
-                            Reject _ -> Reject branches
-                        spec = oldspec { pAnnBranches = bs }
-                        specs =
-                            fromMaybe oldspecs $ replaceMaybe oldspecs pos spec
-                    in  s   { stGitAnnChans =
-                                M.insert (repo, owner) specs chans
-                            }
-                )
-                defBranches
-          )
-        , ( "accept"
-          , mkOptionF
-                getAccept
-                (\ b s ->
-                    let chans = stGitAnnChans s
-                        oldspecs = getSpecs s
-                        oldspec = getSpec s
-                        ctor = filt b
-                        bs = case pAnnBranches oldspec of
-                            Accept l -> ctor l
-                            Reject l -> ctor l
-                        spec = oldspec { pAnnBranches = bs }
-                        specs =
-                            fromMaybe oldspecs $ replaceMaybe oldspecs pos spec
-                    in  s   { stGitAnnChans =
-                                M.insert (repo, owner) specs chans
-                            }
-                )
-                defAccept
-          )
-        , ( "all-commits"
-          , mkOptionF
-                getAll
-                (\ b s ->
-                    let chans = stGitAnnChans s
-                        oldspecs = getSpecs s
-                        oldspec = getSpec s
-                        spec = oldspec { pAnnAllCommits = b }
-                        specs =
-                            fromMaybe oldspecs $ replaceMaybe oldspecs pos spec
-                    in  s   { stGitAnnChans =
-                                M.insert (repo, owner) specs chans
-                            }
-                )
-                defAll
-          )
-        ]
-    , secSubs = M.empty
-    }
-    where
-    defChan = "set-channel-here"
-    defBranches = []
-    defAccept = False
-    filt b = if b then Accept else Reject
-    defFilter = filt defAccept defBranches
-    defAll = False
-    defSpec = PushAnnSpec defChan defFilter defAll
-
-    getSpecs = M.lookupDefault [] (repo, owner) . stGitAnnChans
-    getSpec = fromMaybe defSpec . (!? pos) . getSpecs
-    getChan = pAnnChannel . getSpec
-    getFilter = pAnnBranches . getSpec
-    getBranches = f . getFilter
-        where
-        f (Accept l) = l
-        f (Reject l) = l
-    getAccept = f . getFilter
-        where
-        f (Accept _) = True
-        f (Reject _) = False
-    getAll = pAnnAllCommits . getSpec
-
--- Create a settings section for a git repo, given its name and owner as
--- matched with the details sent to the web listener.
-repoSec :: (String, String) -> [PushAnnSpec] -> (String, SettingsTree)
-repoSec (repo, owner) specs =
-    ( repo ++ '/' : owner
-    , Section
-        { secOpts = M.empty
-        , secSubs = M.fromList $ map mksub [1 .. length specs]
-        }
-    )
-    where
-    mksub i = (show i, pushAnnSpecSec repo owner (i - 1))
-
--- Create a settings section for a news feed, given its label string
-feedSec :: String -> SettingsTree
-feedSec label = Section
-    { secOpts = M.fromList
-        [ ( "url"
-          , mkOptionF'
-                getUrl
-                (\ url s ->
-                    let feeds = stWatchedFeeds s
-                        feed = getFeed s
-                        feed' = feed { nfUrl = url }
-                    in  s { stWatchedFeeds = M.insert label feed' feeds }
-                )
-                defUrl
-                (\ url -> do
-                    cq <- askEnvS feedCmdQueue
-                    active <- liftM getActive getSettings
-                    liftIO $ do
-                        sendCommand cq $ removeFeed label
-                        sendCommand cq $ F.addFeed def
-                            { fcLabel  = label
-                            , fcUrl    = url
-                            , fcActive = active
-                            }
-                )
-          )
-        , ( "active"
-          , mkOptionF'
-                getActive
-                (\ b s ->
-                    let feeds = stWatchedFeeds s
-                        feed = getFeed s
-                        feed' = feed { nfActive = b }
-                    in  s { stWatchedFeeds = M.insert label feed' feeds }
-                )
-                defActive
-                (\ b -> do
-                    cq <- askEnvS feedCmdQueue
-                    liftIO $ sendCommand cq $ setFeedActive label b
-                )
-          )
-        , ( "channels"
-          , mkOptionF
-                getChans
-                (\ chans s ->
-                    let feeds = stWatchedFeeds s
-                        feed@NewsFeed { nfAnnSpec = spec } = getFeed s
-                        feed' = feed
-                            { nfAnnSpec = spec
-                                { nAnnChannels = chans
-                                }
-                            }
-                    in  s { stWatchedFeeds = M.insert label feed' feeds }
-                )
-                defChans
-          )
-        ]
-    , secSubs = M.fromList
-        [ ( "show"
-          , Section
-                { secOpts = M.fromList
-                    [ ( "feed-title"
-                      , mkOptionF
-                            (dispFeedTitle . getFields)
-                            (\ b s ->
-                                let feed@NewsFeed { nfAnnSpec = spec } =
-                                        getFeed s
-                                    fieldsOld = nAnnFields spec
-                                    fields = fieldsOld { dispFeedTitle = b }
-                                    feed' = feed
-                                        { nfAnnSpec = spec
-                                            { nAnnFields = fields
-                                            }
-                                        }
-                                in  s   { stWatchedFeeds =
-                                            M.insert label feed' $
-                                            stWatchedFeeds s
-                                        }
-                            )
-                            (dispFeedTitle defFields)
-                      )
-                    , ( "author"
-                      , mkOptionF
-                            (dispAuthor . getFields)
-                            (\ b s ->
-                                let feed@NewsFeed { nfAnnSpec = spec } =
-                                        getFeed s
-                                    fieldsOld = nAnnFields spec
-                                    fields = fieldsOld { dispAuthor = b }
-                                    feed' = feed
-                                        { nfAnnSpec = spec
-                                            { nAnnFields = fields
-                                            }
-                                        }
-                                in  s   { stWatchedFeeds =
-                                            M.insert label feed' $
-                                            stWatchedFeeds s
-                                        }
-                            )
-                            (dispAuthor defFields)
-                      )
-                    , ( "url"
-                      , mkOptionF
-                            (dispUrl . getFields)
-                            (\ b s ->
-                                let feed@NewsFeed { nfAnnSpec = spec } =
-                                        getFeed s
-                                    fieldsOld = nAnnFields spec
-                                    fields = fieldsOld { dispUrl = b }
-                                    feed' = feed
-                                        { nfAnnSpec = spec
-                                            { nAnnFields = fields
-                                            }
-                                        }
-                                in  s   { stWatchedFeeds =
-                                            M.insert label feed' $
-                                            stWatchedFeeds s
-                                        }
-                            )
-                            (dispUrl defFields)
-                      )
-                    ]
-                , secSubs = M.empty
-                }
-          )
-        ]
-    }
-    where
-    defChans = []
-    defFields = NewsItemFields True True True
-    defSpec = NewsAnnSpec defChans defFields
-    defUrl = ""
-    defActive = False
-    defFeed = NewsFeed defUrl defActive defSpec
-
-    getFeed = M.lookupDefault defFeed label . stWatchedFeeds
-    getUrl = maybe defUrl nfUrl . M.lookup label . stWatchedFeeds
-    getActive = maybe defActive nfActive . M.lookup label . stWatchedFeeds
-    getSpec = maybe defSpec nfAnnSpec . M.lookup label . stWatchedFeeds
-    getChans = nAnnChannels . getSpec
-    getFields = nAnnFields . getSpec
-
--- Create a section for a channel
-chanSec :: String -> SettingsTree
-chanSec chan = Section
-    { secOpts = M.fromList
-        [ ( "track"
-          , mkOptionB
-                (channelIsTracked chan)
-                (bool (stopTrackingChannel chan) (startTrackingChannel chan))
-                False
-          )
-        , ( "count"
-          , mkOptionB
-                (chanIsCounted chan)
-                (bool (stopCountingChan chan) (startCountingChan chan))
-                False
-          )
-        , ( "log"
-          , mkOptionB
-                (channelIsLogged chan)
-                (bool (stopLoggingChannel chan) (startLoggingChannel chan))
-                False
-          )
-        , ( "say-titles"
-          , mkOptionF
-                (getf True csSayTitles)
-                (setf $ \ cs say -> cs { csSayTitles = say })
-                True
-          )
-        , ( "welcome"
-          , mkOptionF
-                (getf False csWelcome)
-                (setf $ \ cs w -> cs { csWelcome = w })
-                False
-          )
-        , ( "folks"
-          , mkOptionF
-                (getf [] csFolks)
-                (setf $ \ cs fs -> cs { csFolks = fs })
-                []
-          )
-        , ( "email"
-          , mkOptionF
-                (getf "(?)" csEmail)
-                (setf $ \ cs s -> cs { csEmail = s })
-                "(?)"
-          )
-        ]
-    , secSubs = M.empty
-    }
-    where
-    defChan = ChanSettings True False [] "(?)"
-    getf e f = maybe e f . M.lookup chan . stChannels
-    setf f v s =
-        let chans = stChannels s
-            cs = M.lookupDefault defChan chan chans
-            cs' = f cs v
-            chans' = M.insert chan cs' chans
-        in  s { stChannels = chans' }
-
--- Create a settings section for a shortcut, given its label string
-shortcutSec :: String -> SettingsTree
-shortcutSec label = Section
-    { secOpts = M.fromList
-        [ ( "prefix"
-          , mkOptionF
-                (getf shPrefix)
-                (setf $ \ cut prefix -> cut { shPrefix = prefix })
-                ""
-          )
-        , ( "before"
-          , mkOptionF
-                (getf shBefore)
-                (setf $ \ cut before -> cut { shBefore = before })
-                ""
-          )
-        , ( "after"
-          , mkOptionF
-                (getf shAfter)
-                (setf $ \ cut after -> cut { shAfter = after })
-                ""
-          )
-        , ( "channels"
-          , mkOptionF
-                (getl shChannels)
-                (setf $ \ cut chans -> cut { shChannels = chans })
-                []
-          )
-        ]
-    , secSubs = M.empty
-    }
-    where
-    err = "ERROR not found"
-    getf f = maybe err f . M.lookup label . stShortcuts
-    getl f = maybe []  f . M.lookup label . stShortcuts
-    setf f v s =
-        let cuts = stShortcuts s
-        in  case M.lookup label cuts of
-                Nothing  -> s
-                Just cut ->
-                    let cut' = f cut v
-                        cuts' = M.insert label cut' cuts
-                    in  s { stShortcuts = cuts' }
-
--- | Build initial settings tree, already inside the session
-initTree :: BotSession ()
-initTree = do
-    cstates <- getChanInfo
-    sets <- getSettings
-    let mapKey f = M.mapWithKey $ \ key _val -> f key
-        tree = Section
-            { secOpts = M.empty
-            , secSubs = M.fromList
-                [ ( "channels"
-                  , Section
-                        { secOpts = M.empty
-                        , secSubs = mapKey chanSec cstates
-                        }
-                  )
-                , ( "repos"
-                  , Section
-                        { secOpts = M.empty
-                        , secSubs = M.fromList $ map (uncurry repoSec) $
-                                    M.toList $ stGitAnnChans sets
-                        }
-                  )
-                , ( "feeds"
-                  , Section
-                        { secOpts = M.empty
-                        , secSubs = mapKey feedSec $ stWatchedFeeds sets
-                        }
-                  )
-                , ( "shortcuts"
-                  , Section
-                        { secOpts = M.empty
-                        , secSubs = mapKey shortcutSec $ stShortcuts sets
-                        }
-                  )
-                ]
-            }
-    modifyState $ \ s -> s { bsSTree = tree }
-
--- | Append a new push ann spec to the settings and a matching tree under the
--- repo section. Return whether succeeded.
-addPushAnnSpec :: String -> String -> String -> BotSession Bool
-addPushAnnSpec repo owner chan = do
-    repos <- liftM stGitAnnChans getSettings
-    case M.lookup (repo, owner) repos of
-        Just specs -> do
-            let specs' = specs ++ [defSpec]
-                repos' = M.insert (repo, owner) specs' repos
-            modifySettings $ \ s -> s { stGitAnnChans = repos' }
-            saveBotSettings
-            let (name, sec) = repoSec (repo, owner) specs'
-                ins = insertSub ["repos", name] sec
-            modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
-            return True
-        Nothing -> return False
-    where
-    defSpec = PushAnnSpec chan (Reject []) False
-
--- | Remove a spec from a repo. Return 'Nothing' on success. Otherwise return
--- whether the error was repo not found ('False') or index too big ('True').
--- The position given is 0-based.
-deletePushAnnSpec :: String -> String -> Int -> BotSession (Maybe Bool)
-deletePushAnnSpec repo owner pos = do
-    repos <- liftM stGitAnnChans getSettings
-    case M.lookup (repo, owner) repos of
-        Just specs ->
-            case splitAt pos specs of
-                (l, [])  -> return $ Just True
-                (l, s:r) -> do
-                    let specs' = l ++ r
-                        repos' = M.insert (repo, owner) specs' repos
-                    modifySettings $ \ s -> s { stGitAnnChans = repos' }
-                    saveBotSettings
-                    let (name, sec) = repoSec (repo, owner) specs'
-                        ins = insertSub ["repos", name] sec
-                    modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
-                    return Nothing
-        Nothing -> return $ Just False
-
--- | Add a new repo to settings and tree. Return whether success, i.e. whether
--- the repo didn't exist and indeed a new one has been created.
-addRepo :: String -> String -> String -> BotSession Bool
-addRepo repo owner chan = do
-    repos <- liftM stGitAnnChans getSettings
-    case M.lookup (repo, owner) repos of
-        Just _  -> return False
-        Nothing -> do
-            let repos' = M.insert (repo, owner) [defSpec] repos
-            modifySettings $ \ s -> s { stGitAnnChans = repos' }
-            saveBotSettings
-            let (name, sec) = repoSec (repo, owner) [defSpec]
-                ins = insertSub ["repos", name] sec
-            modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
-            return True
-    where
-    defSpec = PushAnnSpec chan (Reject []) False
-
--- | Remove a repo from settings and tree. Return whether success, i.e. whether
--- the repo did exist and indeed has been deleted.
-deleteRepo :: String -> String -> BotSession Bool
-deleteRepo repo owner = do
-    repos <- liftM stGitAnnChans getSettings
-    if M.member (repo, owner) repos
-        then do
-            let repos' = M.delete (repo, owner) repos
-            modifySettings $ \ s -> s { stGitAnnChans = repos' }
-            saveBotSettings
-            let name = repo ++ '/' : owner
-                del = deleteSub ["repos", name]
-            modifyState $ \ s -> s { bsSTree = del $ bsSTree s }
-            return True
-        else return False
-
--- | Add a new shortcut to settings and tree. Return whether success, i.e.
--- whether the shortcut didn't exist and indeed a new one has been created.
-addShortcut :: String -> String -> BotSession Bool
-addShortcut label chan = do
-    cuts <- liftM stShortcuts getSettings
-    case M.lookup label cuts of
-        Just _  -> return False
-        Nothing -> do
-            let cuts' = M.insert label defCut cuts
-            modifySettings $ \ s -> s { stShortcuts = cuts' }
-            saveBotSettings
-            let sec = shortcutSec label
-                ins = insertSub ["shortcuts", label] sec
-            modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
-            return True
-    where
-    defCut = Shortcut "PrEfIx" "http://BeFoRe.org/" "/AfTeR.html" [chan]
-
--- | Remove a shortcut from settings and tree. Return whether success, i.e.
--- whether the shortcut did exist and indeed has been deleted.
-deleteShortcut :: String -> BotSession Bool
-deleteShortcut label = do
-    cuts <- liftM stShortcuts getSettings
-    if M.member label cuts
-        then do
-            let cuts' = M.delete label cuts
-            modifySettings $ \ s -> s { stShortcuts = cuts' }
-            saveBotSettings
-            let del = deleteSub ["shortcuts", label]
-            modifyState $ \ s -> s { bsSTree = del $ bsSTree s }
-            return True
-        else return False
-
--- | Add a new channel to state and tree and to be joined from now on. If
--- already exists, nothing happens.
-addChannel :: String -> BotSession ()
-addChannel chan = do
-    selectChannel chan
-    addChannelState chan
-    sets <- getSTree
-    let route = ["channels", chan]
-    unless (route `memberSub` sets) $ do
-        let sec = chanSec chan
-            ins = insertSub route sec
-        modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
-
--- | Add a new feed to settings and tree. Return whether success, i.e. whether
--- the feed didn't exist and indeed a new one has been created.
-addFeed :: String -> String -> BotSession Bool
-addFeed label url = do
-    feeds <- liftM stWatchedFeeds getSettings
-    case M.lookup label feeds of
-        Just _  -> return False
-        Nothing -> do
-            -- Update and save settings
-            let feed = NewsFeed
-                    { nfUrl     = url
-                    , nfActive  = True
-                    , nfAnnSpec = defSpec
-                    }
-                feeds' = M.insert label feed feeds
-            modifySettings $ \ s -> s { stWatchedFeeds = feeds' }
-            saveBotSettings
-            -- Update settings UI tree
-            let sec = feedSec label
-                ins = insertSub ["feeds", label] sec
-            modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
-            -- Send command to update the feed watcher
-            cq <- askEnvS feedCmdQueue
-            liftIO $ sendCommand cq $ F.addFeed $ mkFeed label url
-            return True
-    where
-    defChans = []
-    defFields = NewsItemFields True True True
-    defSpec = NewsAnnSpec defChans defFields
-
--- | Remove a feed from settings and tree. Return whether success, i.e. whether
--- the feed did exist and indeed has been deleted.
-deleteFeed :: String -> BotSession Bool
-deleteFeed label = do
-    feeds <- liftM stWatchedFeeds getSettings
-    if M.member label feeds
-        then do
-            -- Update and save settings
-            let feeds' = M.delete label feeds
-            modifySettings $ \ s -> s { stWatchedFeeds = feeds' }
-            saveBotSettings
-            -- Update settings UI tree
-            let del = deleteSub ["feeds", label]
-            modifyState $ \ s -> s { bsSTree = del $ bsSTree s }
-            -- Send command to update the feed watcher
-            cq <- askEnvS feedCmdQueue
-            liftIO $ sendCommand cq $ removeFeed label
-            return True
-        else return False
-
-showError :: SettingsError -> String
-showError (InvalidPath s)         = s ++ " : Invalid path"
-showError (NoSuchNode r)          = showRoute r ++ " : No such option/section"
-showError (NoSuchOption r)        = showRoute r ++ " : No such option"
-showError (NoSuchSection r)       = showRoute r ++ " : No such section"
-showError (InvalidValueForType s) = s ++ " : Invalid value for option type"
-showError (InvalidValue s)        = s ++ " : Invalid value"
-
-showOptLine :: String -> String -> String -> String
-showOptLine opt op val =
-    encode $ Yellow #> Pure opt <> Teal #> Pure op <> Maroon #> Pure val
-
-showGet :: String -> String -> String
-showGet opt val = showOptLine opt " = " val
-
-showSec :: String -> [String] -> [String] -> String
-showSec path subs opts =
-    let showSub = Pure . ('‣' :)
-        showOpt = Pure . ('•' :)
-        showList = mconcat . intersperse " "
-        pathF = Yellow #> Pure path
-        subsF = Green #> (showList $ map showSub subs)
-        optsF = Purple #> (showList $ map showOpt opts)
-    in  encode $ case (null subs, null opts) of
-            (False, False) -> pathF <> " : " <> subsF <> " | " <> optsF
-            (False, True)  -> pathF <> " : " <> subsF
-            (True, False)  -> pathF <> " : " <> optsF
-            (True, True)   -> pathF <> " : Empty section"
-
--- Remove user-friendliness parts and determine whether given string refers to
--- a potential section (otherwise it could also be an potential option).
-stripPath :: String -> (String, Bool)
-stripPath opt
-    | opt == "*"            = ("", True)
-    | ".*" `isSuffixOf` opt = (take (length opt - 2) opt, True)
-    | otherwise             = (opt, False)
-
-respondGet' :: String -> (String -> BotSession ()) -> BotSession ()
-respondGet' opt send = resp path
-    where
-    (path, sec) = stripPath opt
-    resp = if sec then respSec else respAny
-    respAny path = do
-        result <- query path
-        send $ case result of
-            Left err                  -> showError err
-            Right (Left (subs, opts)) -> showSec path subs opts
-            Right (Right val)         -> showGet path val
-    respSec path = do
-        result <- querySection path
-        send $ case result of
-            Left err           -> showError err
-            Right (subs, opts) -> showSec path subs opts
-
-showSet :: String -> String -> String
-showSet opt val = showOptLine opt " ← " val
-
-respondSet' :: String -> String -> (String -> BotSession ()) -> BotSession ()
-respondSet' opt val send = do
-    merr <- updateOption opt val
-    case merr of
-        Just err -> send $ showError err
-        Nothing  -> send $ showSet opt val
-
-showReset :: String -> String -> String
-showReset opt val = showOptLine opt " ↩ " val
-
-showResetStrange :: String -> String
-showResetStrange opt = opt ++ " : got reset, but I can't find it now"
-
-respondReset' :: String -> (String -> BotSession ()) -> BotSession ()
-respondReset' opt send = do
-    merr <- resetOption opt
-    case merr of
-        Just err -> send $ showError err
-        Nothing  -> do
-            me <- queryOption opt
-            send $ case me of
-                Left _    -> showResetStrange opt
-                Right val -> showReset opt val
-
-help :: OptRoute -> String
-help r = case r of
-    [] -> "Top level of the settings tree."
-    ["channels"] -> "Basic per-channel settings."
-    ["channels", _] -> "Basic settings for the channel."
-    ["channels", _, "log"] ->
-        "Whether events in the channel are logged by the bot locally into a \
-        \log file. Currently nothing is done with these logs. In the future \
-        \they can be used to send people activity they missed (or selected \
-        \parts of it), generate public logs as web pages and record meetings."
-    ["channels", _, "track"] ->
-        "Whether user joins and parts in the channel \
-        \are tracked internally. This is useful for various other features, \
-        \such as memos (see !tell) and listing these events in channel logs. \
-        \Tracking isn't enabled by default, to save bot server hardware \
-        \resources (in particular RAM), especially for cases of many, crowded \
-        \or busy channels."
-    ["channels", _, "count"] ->
-        "Whether channel message logs are maintained (in memory) for this \
-        \channel. If yes, channel history reports (which you can get when you \
-        \join a channel) will also specify how many messages you missed. For \
-        \to work, you must also enable the 'track' option."
-    ["channels", _, "say-titles"] ->
-        "Whether I should detect URLs sent into this channel and send their \
-        \titles."
-    ["channels", _, "welcome"] ->
-        "Whether I should send a welcome message when a new nickname unknown \
-        \to me joins the channel, and the channel is quiet for some time."
-    ["channels", _, "folks"] ->
-        "List of main people (nicknames) in the channel. This is used in the \
-        \welcome messages, and possibly other places."
-    ["channels", _, "email"] ->
-        "Email address (possibly of a mailing list) for async discussions. If \
-        \you ask a question and nobody responds for a while, you can send \
-        \your question there. Also good for long, documented discussions and \
-        \generally the things email is better suited for than IRC."
-    ["repos"] -> "Git repo event announcement details."
-    ["repos", _] ->
-        "Event announcement details for a Git repo, specified by its name and \
-        \its \"owner\", (a username or an organization name). The name and \
-        \owner match the ones used by the dev platform which hosts the repo. \
-        \Announcment details are given as a set of specifications, one for \
-        \each IRC channel where you want the events to be announced."
-    ["repos", _, _] ->
-        "A Git repo event announcement specification for a specific channel. \
-        \It specifies the channel and defines filters to determine which \
-        \events should be announced."
-    ["repos", _, _, "branches"] ->
-        "A list of zero or more git branch names to filter by. If the \
-        \\"accept\" option is True, this is whitelist of branches whose \
-        \commits to announce (and the rest won't be announced). Otherwise, \
-        \it's a blacklist of branches not to announce (and all the rest will \
-        \be announced). By default the list is empty, and you can reset it to \
-        \empty using !reset."
-    ["repos", _, _, "channel"] ->
-        "IRC channel into which to announce the repo events."
-    ["repos", _, _, "all-commits"] ->
-        "Whether to announce all commits into the channel, or shorten long \
-        \pushes to avoid filling the channel with very long announcements. \
-        \For example, if you push 20 commits at once, you may prefer to see \
-        \just a summary or a partial report, and not have the channel filled \
-        \with a very long sequence of messages. The default is False, i.e. do \
-        \shorten long announcements."
-    ["repos", _, _, "accept"] ->
-        "Whether the branch list specified by the \"branches\" option is a \
-        \whitelist of branches whose commits to announce (True), or a \
-        \blacklist of branches not to announce (False). By default it's \
-        \False, and the branch list is empty, which together mean \"reject no \
-        \branches\", or in other words announce commits of *all* branches."
-    ["feeds"] -> "News feed item announcement details."
-    ["feeds", _] -> "Details for announcing new feed items for this feed."
-    ["feeds", _, "url"] -> "URL of the feed."
-    ["feeds", _, "active"] -> "Whether the feed is being watched."
-    ["feeds", _, "channels"] ->
-        "List of IRC channels into which to announce new items from the feed."
-    ["feeds", _, "show"] ->
-        "Determines which information about the new feed items should be \
-        \specified in the announcements."
-    ["feeds", _, "show", "author"] ->
-        "Whether to specify the news item author when announcing the new item."
-    ["feeds", _, "show", "feed-title"] ->
-        "Whether to specify the feed title when announcing a new item."
-    ["feeds", _, "show", "url"] ->
-        "Whether to specify the item URL when announcing the new item."
-    ["shortcuts"] -> "List of available shortcuts."
-    ["shortcuts", _] -> "Details of this shortcut."
-    ["shortcuts", _, "prefix"] ->
-        "A string by which the shortcut is identified. For example, if you'd \
-        \like “TKT-258” to refer to ticket #258, set the prefix to “TKT-”."
-    ["shortcuts", _, "before"] ->
-        "In the full form into which the shortcut is expanded, this is the \
-        \beginning of the string. For example, “http://funbot.org/tickets/”."
-    ["shortcuts", _, "after"] ->
-        "In the full form into which the shortcut is expanded, this is the \
-        \end of the string. For example, “.html”."
-    ["shortcuts", _, "channels"] ->
-        "List of IRC channels in which this shortcut applies."
-    _ -> "No help for this item."
-
-respondSettingsHelp :: String -> (String -> BotSession ()) -> BotSession Bool
-respondSettingsHelp path send =
-    let p = fst $ stripPath path
-    in  case parseRoute p of
-            Just r -> do
-                send $ p ++ " : " ++ help r
-                return True
-            Nothing -> return False
-
-saveInterval = 3 :: Second
-
-loadBotSettings :: IO Settings
-loadBotSettings = do
-    r <- loadState $
-         stateFilePath settingsFilename (cfgStateRepo configuration)
-    case r of
-        Left (False, e) -> error $ "Failed to read settings file: " ++ e
-        Left (True, e)  -> error $ "Failed to parse settings file: " ++ e
-        Right s         -> return s
-
-mkSaveBotSettings :: IO (Settings -> IO ())
-mkSaveBotSettings =
-    mkSaveStateChoose
-        stateSaveInterval
-        settingsFilename
-        (cfgStateRepo configuration)
-        "auto commit by funbot"
-
-saveBotSettings :: BotSession ()
-saveBotSettings = do
-    sets <- getSettings
-    save <- askEnvS saveSettings
-    liftIO $ save sets
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+-- For irc-fun-color StyledString
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Settings
+    ( respondGet'
+    , respondSet'
+    , respondReset'
+    , respondSettingsHelp
+    )
+where
+
+import Control.Applicative
+import Control.Monad (liftM, mzero, unless)
+import Control.Monad.IO.Class (liftIO)
+import Data.Aeson hiding (encode)
+import Data.Bool (bool)
+import Data.Char (toLower)
+import Data.Default.Class (def)
+import Data.JsonState
+import Data.List (intercalate, intersperse, isSuffixOf, sort)
+import Data.Maybe (catMaybes, fromMaybe)
+import Data.Monoid
+import Data.Settings.Interface
+import Data.Settings.Option
+import Data.Settings.Route
+import Data.Settings.Section (deleteSub, insertSub, memberSub)
+import Data.Settings.Types
+import Data.Text (Text)
+import Data.Time.Units (Second)
+import FunBot.Config (stateSaveInterval, configuration, settingsFilename)
+import FunBot.Settings.Help
+import FunBot.Settings.Instances ()
+import FunBot.Types
+import FunBot.Util
+import Network.IRC.Fun.Bot.Chat
+import Network.IRC.Fun.Bot.IrcLog
+import Network.IRC.Fun.Bot.MsgCount
+import Network.IRC.Fun.Bot.Nicks
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Bot.Types (Config (cfgStateRepo))
+import Network.IRC.Fun.Color
+import Network.IRC.Fun.Types.Base (MsgContent (..))
+import Web.Feed.Collect hiding (addFeed)
+
+import qualified Data.HashMap.Lazy as M
+import qualified Data.Text as T
+import qualified Web.Feed.Collect as F (addFeed)
+
+showError :: SettingsError -> Text
+showError (InvalidPath t)         = t <> " : Invalid path"
+showError (NoSuchNode r)          = showRoute r <> " : No such option/section"
+showError (NoSuchOption r)        = showRoute r <> " : No such option"
+showError (NoSuchSection r)       = showRoute r <> " : No such section"
+showError (InvalidValueForType t) = t <> " : Invalid value for option type"
+showError (InvalidValue t)        = t <> " : Invalid value"
+
+showOptLine :: Text -> Text -> Text -> Text
+showOptLine opt op val =
+    encode $ Yellow #> plain opt <> Teal #> plain op <> Maroon #> plain val
+
+showGet :: Text -> Text -> Text
+showGet opt val = showOptLine opt " = " val
+
+showSec :: OptPath -> [Text] -> [Text] -> Text
+showSec path subs opts =
+    let showSub = ('‣' `T.cons`)
+        showOpt = ('•' `T.cons`)
+        showList = T.unwords . sort
+        pathF = Yellow #> plain path
+        subsF = Green #> plain (showList $ map showSub subs)
+        optsF = Purple #> plain (showList $ map showOpt opts)
+    in  encode $ case (null subs, null opts) of
+            (False, False) -> pathF <> " : " <> subsF <> " | " <> optsF
+            (False, True)  -> pathF <> " : " <> subsF
+            (True, False)  -> pathF <> " : " <> optsF
+            (True, True)   -> pathF <> " : Empty section"
+
+-- Remove user-friendliness parts and determine whether given string refers to
+-- a potential section (otherwise it could also be an potential option).
+stripPath :: Text -> (Text, Bool)
+stripPath opt
+    | opt == "*"              = (T.empty, True)
+    | ".*" `T.isSuffixOf` opt = (T.dropEnd 2 opt, True)
+    | otherwise               = (opt, False)
+
+respondGet' :: OptPath -> (MsgContent -> BotSession ()) -> BotSession ()
+respondGet' opt send = resp path
+    where
+    (path, sec) = stripPath opt
+    resp = if sec then respSec else respAny
+    respAny path = do
+        result <- query path
+        send $ MsgContent $ case result of
+            Left err                  -> showError err
+            Right (Left (subs, opts)) -> showSec path subs opts
+            Right (Right val)         -> showGet path val
+    respSec path = do
+        result <- querySection path
+        send $ MsgContent $ case result of
+            Left err           -> showError err
+            Right (subs, opts) -> showSec path subs opts
+
+showSet :: Text -> Text -> Text
+showSet opt val = showOptLine opt " ← " val
+
+respondSet'
+    :: OptPath
+    -> Text
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+respondSet' opt val send = do
+    merr <- updateOption opt val
+    case merr of
+        Just err -> send $ MsgContent $ showError err
+        Nothing  -> send $ MsgContent $ showSet opt val
+
+showReset :: Text -> Text -> Text
+showReset opt val = showOptLine opt " ↩ " val
+
+showResetStrange :: Text -> Text
+showResetStrange opt = opt <> " : got reset, but I can’t find it now"
+
+respondReset' :: OptPath -> (MsgContent -> BotSession ()) -> BotSession ()
+respondReset' opt send = do
+    merr <- resetOption opt
+    case merr of
+        Just err -> send $ MsgContent $ showError err
+        Nothing  -> do
+            me <- queryOption opt
+            send $ MsgContent $ case me of
+                Left _    -> showResetStrange opt
+                Right val -> showReset opt val
+
+respondSettingsHelp
+    :: OptPath
+    -> (MsgContent -> BotSession ())
+    -> BotSession Bool
+respondSettingsHelp path send =
+    let p = fst $ stripPath path
+    in  case parseRoute p of
+            Just r -> do
+                send $ MsgContent $ p <> " : " <> help r
+                return True
+            Nothing -> return False
+
diff --git a/src/FunBot/Settings/Help.hs b/src/FunBot/Settings/Help.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/Help.hs
@@ -0,0 +1,123 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Settings.Help
+    ( help
+    )
+where
+
+import Data.Settings.Types (OptRoute)
+import Data.Text (Text)
+
+help :: OptRoute -> Text
+help r = case r of
+    [] -> "Top level of the settings tree."
+    ["channels"] -> "Basic per-channel settings."
+    ["channels", _] -> "Basic settings for the channel."
+    ["channels", _, "log"] ->
+        "Whether events in the channel are logged by the bot locally into a \
+        \log file. Currently nothing is done with these logs. In the future \
+        \they can be used to send people activity they missed (or selected \
+        \parts of it), generate public logs as web pages and record meetings."
+    ["channels", _, "track"] ->
+        "Whether user joins and parts in the channel \
+        \are tracked internally. This is useful for various other features, \
+        \such as memos (see !tell) and listing these events in channel logs. \
+        \Tracking isn't enabled by default, to save bot server hardware \
+        \resources (in particular RAM), especially for cases of many, crowded \
+        \or busy channels."
+    ["channels", _, "count"] ->
+        "Whether channel message logs are maintained (in memory) for this \
+        \channel. If yes, channel history reports (which you can get when you \
+        \join a channel) will also specify how many messages you missed. For \
+        \to work, you must also enable the 'track' option."
+    ["channels", _, "say-titles"] ->
+        "Whether I should detect URLs sent into this channel and send their \
+        \titles."
+    ["channels", _, "welcome"] ->
+        "Whether I should send a welcome message when a new nickname unknown \
+        \to me joins the channel, and the channel is quiet for some time."
+    ["channels", _, "folks"] ->
+        "List of main people (nicknames) in the channel. This is used in the \
+        \welcome messages, and possibly other places."
+    ["channels", _, "email"] ->
+        "Email address (possibly of a mailing list) for async discussions. If \
+        \you ask a question and nobody responds for a while, you can send \
+        \your question there. Also good for long, documented discussions and \
+        \generally the things email is better suited for than IRC."
+    ["repos"] -> "Git repo event announcement details."
+    ["repos", _] ->
+        "Event announcement details for a Git repo, specified by its name and \
+        \its \"owner\", (a username or an organization name). The name and \
+        \owner match the ones used by the dev platform which hosts the repo. \
+        \Announcment details are given as a set of specifications, one for \
+        \each IRC channel where you want the events to be announced."
+    ["repos", _, _] ->
+        "A Git repo event announcement specification for a specific channel. \
+        \It specifies the channel and defines filters to determine which \
+        \events should be announced."
+    ["repos", _, _, "branches"] ->
+        "A list of zero or more git branch names to filter by. If the \
+        \\"accept\" option is True, this is whitelist of branches whose \
+        \commits to announce (and the rest won't be announced). Otherwise, \
+        \it's a blacklist of branches not to announce (and all the rest will \
+        \be announced). By default the list is empty, and you can reset it to \
+        \empty using !reset."
+    ["repos", _, _, "channel"] ->
+        "IRC channel into which to announce the repo events."
+    ["repos", _, _, "all-commits"] ->
+        "Whether to announce all commits into the channel, or shorten long \
+        \pushes to avoid filling the channel with very long announcements. \
+        \For example, if you push 20 commits at once, you may prefer to see \
+        \just a summary or a partial report, and not have the channel filled \
+        \with a very long sequence of messages. The default is False, i.e. do \
+        \shorten long announcements."
+    ["repos", _, _, "accept"] ->
+        "Whether the branch list specified by the \"branches\" option is a \
+        \whitelist of branches whose commits to announce (True), or a \
+        \blacklist of branches not to announce (False). By default it's \
+        \False, and the branch list is empty, which together mean \"reject no \
+        \branches\", or in other words announce commits of *all* branches."
+    ["feeds"] -> "News feed item announcement details."
+    ["feeds", _] -> "Details for announcing new feed items for this feed."
+    ["feeds", _, "url"] -> "URL of the feed."
+    ["feeds", _, "active"] -> "Whether the feed is being watched."
+    ["feeds", _, "channels"] ->
+        "List of IRC channels into which to announce new items from the feed."
+    ["feeds", _, "show"] ->
+        "Determines which information about the new feed items should be \
+        \specified in the announcements."
+    ["feeds", _, "show", "author"] ->
+        "Whether to specify the news item author when announcing the new item."
+    ["feeds", _, "show", "feed-title"] ->
+        "Whether to specify the feed title when announcing a new item."
+    ["feeds", _, "show", "url"] ->
+        "Whether to specify the item URL when announcing the new item."
+    ["shortcuts"] -> "List of available shortcuts."
+    ["shortcuts", _] -> "Details of this shortcut."
+    ["shortcuts", _, "prefix"] ->
+        "A string by which the shortcut is identified. For example, if you'd \
+        \like “TKT-258” to refer to ticket #258, set the prefix to “TKT-”."
+    ["shortcuts", _, "before"] ->
+        "In the full form into which the shortcut is expanded, this is the \
+        \beginning of the string. For example, “http://funbot.org/tickets/”."
+    ["shortcuts", _, "after"] ->
+        "In the full form into which the shortcut is expanded, this is the \
+        \end of the string. For example, “.html”."
+    ["shortcuts", _, "channels"] ->
+        "List of IRC channels in which this shortcut applies."
+    _ -> "No help for this item."
diff --git a/src/FunBot/Settings/Instances.hs b/src/FunBot/Settings/Instances.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/Instances.hs
@@ -0,0 +1,308 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+-- For the 'MonadSettings' instance
+{-# LANGUAGE MultiParamTypeClasses, TypeSynonymInstances, FlexibleInstances #-}
+
+-- For JSON field names
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Settings.Instances () where
+
+import Control.Applicative
+import Data.Aeson
+import Data.Aeson.Types (typeMismatch)
+import Data.Bool (bool)
+import Data.CaseInsensitive (CI)
+import Data.Hashable (Hashable)
+import Data.Maybe (catMaybes)
+import Data.Monoid ((<>))
+import Data.Settings.Types
+import FunBot.Types
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Types.Base (Nickname (..))
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.HashMap.Lazy as M
+import qualified Data.HashSet as S
+import qualified Data.Text as T
+
+instance MonadSettings BotSession Settings where
+    getSettings      = getStateS bsSettings
+
+    putSettings s    = modifyState $ \ st -> st { bsSettings = s }
+
+    modifySettings f =
+        modifyState $ \ st -> st { bsSettings = f $ bsSettings st }
+
+    getSTree         = getStateS bsSTree
+
+instance OptionValue Bool where
+    readOption s
+        | s' `elem` ["off", "false", "no",  "n", "0", "[_]"] = Just False
+        | s' `elem` ["on",  "true",  "yes", "y", "1", "[x]"] = Just True
+        | otherwise                                          = Nothing
+        where
+        s' = T.toLower s
+    showOption = bool "False" "True"
+    typeName = const "Boolean"
+
+instance OptionValue T.Text where
+    readOption = Just
+    showOption = id
+    typeName = const "String"
+
+instance OptionValue [T.Text] where
+    readOption = mapM readOption . T.split (== ',')
+    showOption = T.intercalate "," . map showOption
+    typeName   = const "List"
+
+instance FromJSON a => FromJSON (Filter a) where
+    parseJSON (Object o) =
+        Accept <$> o .: "accept" <|>
+        Reject <$> o .: "reject"
+    parseJSON v          = typeMismatch "Filter" v
+
+instance ToJSON a => ToJSON (Filter a) where
+    toJSON (Accept l) = object [ "accept" .= l ]
+    toJSON (Reject l) = object [ "reject" .= l ]
+
+instance FromJSON RepoAnnSpec where
+    parseJSON (Object o) =
+        RepoAnnSpec <$>
+        o .: "channel" <*>
+        o .: "branches" <*>
+        o .: "all-commits" <*>
+        o .: "commits" <*>
+        o .: "issues" <*>
+        o .: "merge-requests" <*>
+        o .: "snippets" <*>
+        o .: "notes" <*>
+        o .: "new" <*>
+        o .: "old" <*>
+        o .: "untimed"
+    parseJSON v          = typeMismatch "RepoAnnSpec" v
+
+instance ToJSON RepoAnnSpec where
+    toJSON ras = object
+        [ "channel"        .= rasChannel ras
+        , "branches"       .= rasBranches ras
+        , "all-commits"    .= rasAllCommits ras
+        , "commits"        .= rasCommits ras
+        , "issues"         .= rasIssues ras
+        , "merge-requests" .= rasMergeRequests ras
+        , "snippets"       .= rasSnippets ras
+        , "notes"          .= rasNotes ras
+        , "new"            .= rasNew ras
+        , "old"            .= rasOld ras
+        , "untimed"        .= rasUntimed ras
+        ]
+
+instance FromJSON NewsItemFields where
+    parseJSON (Object o) =
+        NewsItemFields <$>
+        o .: "show-feed-title" <*>
+        o .: "show-author" <*>
+        o .: "show-url"
+    parseJSON v          = typeMismatch "NewsItemFields" v
+
+instance ToJSON NewsItemFields where
+    toJSON (NewsItemFields ftitle author url) = object
+        [ "show-feed-title" .= ftitle
+        , "show-author"     .= author
+        , "show-url"        .= url
+        ]
+
+instance FromJSON NewsAnnSpec where
+    parseJSON (Object o) =
+        NewsAnnSpec <$>
+        o .: "channels" <*>
+        o .: "fields"
+    parseJSON v          = typeMismatch "NewsAnnSpec" v
+
+instance ToJSON NewsAnnSpec where
+    toJSON (NewsAnnSpec channels fields) = object
+        [ "channels" .= channels
+        , "fields"   .= fields
+        ]
+
+instance FromJSON NewsFeed where
+    parseJSON (Object o) =
+        NewsFeed <$>
+        o .: "url" <*>
+        o .: "active" <*>
+        o .: "ann-spec"
+    parseJSON v          = typeMismatch "NewsFeed" v
+
+instance ToJSON NewsFeed where
+    toJSON (NewsFeed url active spec) = object
+        [ "url"      .= url
+        , "active"   .= active
+        , "ann-spec" .= spec
+        ]
+
+instance FromJSON a => FromJSON (M.HashMap (CI T.Text) a) where
+    parseJSON v =
+        let f (t, x) = (CI.mk t, x)
+        in  M.fromList . map f . M.toList <$> parseJSON v
+
+instance ToJSON a => ToJSON (M.HashMap (CI T.Text) a) where
+    toJSON m =
+        let f (t, x) = (CI.original t, x)
+        in  toJSON $ M.fromList $ map f $ M.toList m
+
+instance FromJSON a => FromJSON (M.HashMap (RepoSpace, RepoName) a) where
+    parseJSON v =
+        let mkpair (t, x) =
+                case T.split (== '/') t of
+                    [space, repo] ->
+                        if T.null space || T.null repo
+                            then Nothing
+                            else Just
+                                    ( ( RepoSpace $ CI.mk space
+                                      , RepoName $ CI.mk repo
+                                      )
+                                    , x
+                                    )
+                    _ -> Nothing
+        in  M.fromList . catMaybes . map mkpair . M.toList <$> parseJSON v
+
+instance ToJSON a => ToJSON (M.HashMap (RepoSpace, RepoName) a) where
+    toJSON m =
+        let unpair ((RepoSpace s, RepoName r), x) = (s <> "/" <> r, x)
+        in  toJSON $ M.fromList $ map unpair $ M.toList m
+
+instance FromJSON a => FromJSON (M.HashMap DevHostLabel a) where
+    parseJSON v =
+        let f (h, x) = (DevHostLabel h, x)
+        in  M.fromList . map f . M.toList <$> parseJSON v
+
+instance ToJSON a => ToJSON (M.HashMap DevHostLabel a) where
+    toJSON m =
+        let f (DevHostLabel h, x) = (h, x)
+        in  toJSON $ M.fromList $ map f $ M.toList m
+
+instance FromJSON a => FromJSON (M.HashMap DevHost a) where
+    parseJSON v =
+        let f (h, x) = (DevHost h, x)
+        in  M.fromList . map f . M.toList <$> parseJSON v
+
+instance ToJSON a => ToJSON (M.HashMap DevHost a) where
+    toJSON m =
+        let f (DevHost h, x) = (h, x)
+        in  toJSON $ M.fromList $ map f $ M.toList m
+
+instance FromJSON a => FromJSON (M.HashMap Nickname a) where
+    parseJSON v =
+        let f (n, x) = (Nickname n, x)
+        in  M.fromList . map f . M.toList <$> parseJSON v
+
+instance ToJSON a => ToJSON (M.HashMap Nickname a) where
+    toJSON m =
+        let f (Nickname n, x) = (n, x)
+        in  toJSON $ M.fromList $ map f $ M.toList m
+
+instance FromJSON a => FromJSON (M.HashMap FeedLabel a) where
+    parseJSON v =
+        let f (l, x) = (FeedLabel l, x)
+        in  M.fromList . map f . M.toList <$> parseJSON v
+
+instance ToJSON a => ToJSON (M.HashMap FeedLabel a) where
+    toJSON m =
+        let f (FeedLabel l, x) = (l, x)
+        in  toJSON $ M.fromList $ map f $ M.toList m
+
+instance FromJSON a => FromJSON (M.HashMap ShortcutLabel a) where
+    parseJSON v =
+        let f (l, x) = (ShortcutLabel l, x)
+        in  M.fromList . map f . M.toList <$> parseJSON v
+
+instance ToJSON a => ToJSON (M.HashMap ShortcutLabel a) where
+    toJSON m =
+        let f (ShortcutLabel l, x) = (l, x)
+        in  toJSON $ M.fromList $ map f $ M.toList m
+
+instance FromJSON a => FromJSON (M.HashMap LocationLabel a) where
+    parseJSON v =
+        let f (l, x) = (LocationLabel l, x)
+        in  M.fromList . map f . M.toList <$> parseJSON v
+
+instance ToJSON a => ToJSON (M.HashMap LocationLabel a) where
+    toJSON m =
+        let f (LocationLabel l, x) = (l, x)
+        in  toJSON $ M.fromList $ map f $ M.toList m
+
+instance FromJSON Shortcut where
+    parseJSON (Object o) =
+        Shortcut <$>
+        o .: "prefix" <*>
+        o .: "before" <*>
+        o .: "after" <*>
+        o .: "channels"
+    parseJSON v          = typeMismatch "Shortcut" v
+
+instance ToJSON Shortcut where
+    toJSON (Shortcut prefix before after chans) = object
+        [ "prefix"   .= prefix
+        , "before"   .= before
+        , "after"    .= after
+        , "channels" .= chans
+        ]
+
+instance FromJSON ChanSettings where
+    parseJSON (Object o) =
+        ChanSettings <$>
+        o .: "say-titles"                      <*>
+        o .: "welcome"                         <*>
+        (map Nickname <$> o .: "folks")        <*>
+        o .: "email"                           <*>
+        (M.map Location <$> o .: "locations")  <*>
+        (S.map Nickname <$> o .: "puppeteers") <*>
+        o .: "browse"
+    parseJSON v          = typeMismatch "ChanSettings" v
+
+instance ToJSON ChanSettings where
+    toJSON (ChanSettings sayTitles welcome folks email locs pts url) = object
+        [ "say-titles" .= sayTitles
+        , "welcome"    .= welcome
+        , "folks"      .= map unNickname folks
+        , "email"      .= email
+        , "locations"  .= M.map unLocation locs
+        , "puppeteers" .= S.map unNickname pts
+        , "browse"     .= url
+        ]
+
+instance FromJSON Settings where
+    parseJSON (Object o) =
+        Settings <$>
+        o .: "repos"                              <*>
+        o .: "feeds"                              <*>
+        o .: "shortcuts"                          <*>
+        o .: "channels"                           <*>
+        (M.map DevHostLabel <$> o .: "dev-hosts") <*>
+        (M.map Location <$> o .: "locations")     <*>
+        (S.map Nickname <$> o .: "puppeteers")
+    parseJSON v          = typeMismatch "Settings" v
+
+instance ToJSON Settings where
+    toJSON (Settings repos feeds shortcuts channels hosts locs pts) = object
+        [ "repos"      .= repos
+        , "feeds"      .= feeds
+        , "shortcuts"  .= shortcuts
+        , "channels"   .= channels
+        , "dev-hosts"  .= M.map unDevHostLabel hosts
+        , "locations"  .= M.map unLocation locs
+        , "puppeteers" .= S.map unNickname pts
+        ]
diff --git a/src/FunBot/Settings/MkOption.hs b/src/FunBot/Settings/MkOption.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/MkOption.hs
@@ -0,0 +1,68 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+module FunBot.Settings.MkOption
+    ( mkOptionF
+    , mkOptionF'
+    , mkOptionB
+    )
+where
+
+import Data.Settings.Option
+import Data.Settings.Types
+import FunBot.Settings.Persist
+import FunBot.Types
+import Network.IRC.Fun.Bot.State (saveBotState)
+
+-- | An option whose value is held by funbot's 'Settings' and saved into its
+-- settings file.
+mkOptionF :: OptionValue v
+          => (Settings -> v)             -- ^ Get
+          -> (v -> Settings -> Settings) -- ^ Set which never fails
+          -> v                           -- ^ Default value for reset
+          -> SettingsOption
+mkOptionF get set defval = mkOptionS get set' reset cb
+    where
+    set' v s = Just $ set v s
+    reset s = (Just defval, set defval s)
+    cb = const saveBotSettings
+
+-- | A variant of 'mkOptionF' which accepts a callback to run after the default
+-- one.
+mkOptionF' :: OptionValue v
+           => (Settings -> v)             -- ^ Get
+           -> (v -> Settings -> Settings) -- ^ Set which never fails
+           -> v                           -- ^ Default value for reset
+           -> (v -> BotSession ())        -- ^ Additional callback
+           -> SettingsOption
+mkOptionF' get set defval cbx = mkOptionS get set' reset cb
+    where
+    set' v s = Just $ set v s
+    reset s = (Just defval, set defval s)
+    cb v = saveBotSettings >> cbx v
+
+-- | An option whose value is held by irc-fun-bot's 'BotState' and saved into
+-- its state file.
+mkOptionB :: OptionValue v
+          => BotSession v         -- ^ Get
+          -> (v -> BotSession ()) -- ^ Set which never fails
+          -> v                    -- ^ Default value for reset
+          -> SettingsOption
+mkOptionB get set defval = mkOptionV get set' reset
+    where
+    setTo val = set val >> cb val
+    set' val = setTo val >> return True
+    reset = setTo defval
+    cb = const saveBotState
diff --git a/src/FunBot/Settings/Persist.hs b/src/FunBot/Settings/Persist.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/Persist.hs
@@ -0,0 +1,56 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+module FunBot.Settings.Persist
+    ( loadBotSettings
+    , mkSaveBotSettings
+    , saveBotSettings
+    )
+where
+
+import Control.Monad.IO.Class (liftIO)
+import Data.JsonState
+import Data.Settings.Types (getSettings)
+import Data.Time.Units
+import FunBot.Config (configuration, settingsFilename, stateSaveInterval)
+import FunBot.Settings.Instances
+import FunBot.Types
+import Network.IRC.Fun.Bot.State (askEnvS)
+import Network.IRC.Fun.Bot.Types
+
+saveInterval = 3 :: Second
+
+loadBotSettings :: IO Settings
+loadBotSettings = do
+    r <- loadState $
+         stateFilePath settingsFilename (cfgStateRepo configuration)
+    case r of
+        Left (False, e) -> error $ "Failed to read settings file: " ++ e
+        Left (True, e)  -> error $ "Failed to parse settings file: " ++ e
+        Right s         -> return s
+
+mkSaveBotSettings :: IO (Settings -> IO ())
+mkSaveBotSettings =
+    mkSaveStateChoose
+        stateSaveInterval
+        settingsFilename
+        (cfgStateRepo configuration)
+        "auto commit by funbot"
+
+saveBotSettings :: BotSession ()
+saveBotSettings = do
+    sets <- getSettings
+    save <- askEnvS saveSettings
+    liftIO $ save sets
diff --git a/src/FunBot/Settings/Sections.hs b/src/FunBot/Settings/Sections.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/Sections.hs
@@ -0,0 +1,104 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Settings.Sections
+    ( initTree
+    )
+where
+
+import Data.Maybe (fromMaybe)
+import Data.Monoid ((<>))
+import Data.Sequence (Seq, (|>), (><), ViewL (..))
+import Data.Settings.Section
+import Data.Settings.Types
+import FunBot.Settings.MkOption
+import FunBot.Settings.Persist
+import FunBot.Settings.Sections.Channels
+import FunBot.Settings.Sections.DevHosts
+import FunBot.Settings.Sections.Feeds
+import FunBot.Settings.Sections.Locations
+import FunBot.Settings.Sections.Repos
+import FunBot.Settings.Sections.Shortcuts
+import FunBot.Types
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Types.Base (Channel (..))
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.HashMap.Lazy as M
+import qualified Data.Sequence as Q
+import qualified Data.Text as T
+
+-- | Build initial settings tree, already inside the session
+initTree :: BotSession ()
+initTree = do
+    cinfo <- getChanInfo
+    sets <- getSettings
+    let chans = stChannels sets
+        locs = M.map (M.keys . csLocations) chans
+        defs = M.map (const []) cinfo
+        locs' = (locs `M.intersection` defs) `M.union` defs
+        mapKey f g = M.fromList . map (\ k -> (f k, g k)) . M.keys
+        mapBoth f g = M.fromList . map (\ (k, v) -> (f k, g k v)) . M.toList
+        map' f = M.fromList . map f . M.keys
+        tree = Section
+            { secOpts = M.empty
+            , secSubs = M.fromList
+                [ ( "channels"
+                  , Section
+                        { secOpts = M.empty
+                        , secSubs = mapBoth unChannel chanSec locs'
+                        }
+                  )
+                , ( "repos"
+                  , Section
+                        { secOpts = M.empty
+                        , secSubs =
+                            mapBoth unDevHostLabel hostSection $
+                            stGitAnnChans sets
+                        }
+                  )
+                , ( "feeds"
+                  , Section
+                        { secOpts = M.empty
+                        , secSubs =
+                            mapKey (CI.original . unFeedLabel) feedSec $
+                            stWatchedFeeds sets
+                        }
+                  )
+                , ( "shortcuts"
+                  , Section
+                        { secOpts = M.empty
+                        , secSubs =
+                            mapKey (CI.original . unShortcutLabel) shortcutSec
+                            $ stShortcuts sets
+                        }
+                  )
+                , ( "dev-hosts"
+                  , Section
+                        { secOpts = map' devHostOption $ stDevHosts sets
+                        , secSubs = M.empty
+                        }
+                  )
+                , ( "locations"
+                  , Section
+                        { secOpts = map' locationOption $ stLocations sets
+                        , secSubs = M.empty
+                        }
+                  )
+                ]
+            }
+    modifyState $ \ s -> s { bsSTree = tree }
diff --git a/src/FunBot/Settings/Sections/Channels.hs b/src/FunBot/Settings/Sections/Channels.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/Sections/Channels.hs
@@ -0,0 +1,212 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Settings.Sections.Channels
+    ( chanSec
+    , addChannel
+    , addLocalLocation
+    , removeLocalLocation
+    )
+where
+
+import Control.Monad (unless, void)
+import Data.Bool (bool)
+import Data.Maybe (fromMaybe)
+import Data.Monoid ((<>))
+import Data.Sequence (Seq, (|>), (><), ViewL (..))
+import Data.Settings.Section
+import Data.Settings.Types
+import FunBot.Settings.MkOption
+import FunBot.Settings.Persist
+import FunBot.Types
+import Network.IRC.Fun.Bot.IrcLog
+import Network.IRC.Fun.Bot.MsgCount
+import Network.IRC.Fun.Bot.Nicks
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Types.Base (Channel (..), Nickname (..))
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.HashMap.Lazy as M
+import qualified Data.HashSet as S
+import qualified Data.Sequence as Q
+import qualified Data.Text as T
+
+defChan = ChanSettings True False [] "(?)" M.empty S.empty Nothing
+
+locationOption chan l@(LocationLabel t) =
+                    let defl = "(?)"
+                        getl l sets = fromMaybe defl $ do
+                            cs <- M.lookup chan $ stChannels sets
+                            loc <- M.lookup l $ csLocations cs
+                            return $ unLocation loc
+                        setl l v sets =
+                            let chans = stChannels sets
+                                cs = M.lookupDefault defChan chan chans
+                                locs = csLocations cs
+                                locs' = M.insert l (Location v) locs
+                                cs' = cs { csLocations = locs' }
+                                chans' = M.insert chan cs' chans
+                            in  sets { stChannels = chans' }
+                        f l = mkOptionF (getl l) (setl l) defl
+                    in  (CI.original t, f l)
+
+-- | Create a section for a channel.
+chanSec :: Channel -> [LocationLabel] -> SettingsTree
+chanSec chan lls = Section
+    { secOpts = M.fromList
+        [ ( "track"
+          , mkOptionB
+                (channelIsTracked chan)
+                (bool (stopTrackingChannel chan) (startTrackingChannel chan))
+                False
+          )
+        , ( "count"
+          , mkOptionB
+                (chanIsCounted chan)
+                (bool (stopCountingChan chan) (startCountingChan chan))
+                False
+          )
+        , ( "log"
+          , mkOptionB
+                (channelIsLogged chan)
+                (bool (stopLoggingChannel chan) (startLoggingChannel chan))
+                False
+          )
+        , ( "def-response"
+          , mkOptionB
+                (defRespEnabled chan)
+                (void . setDefResp chan)
+                True
+          )
+        , ( "say-titles"
+          , mkOptionF
+                (getf True csSayTitles)
+                (setf $ \ cs say -> cs { csSayTitles = say })
+                True
+          )
+        , ( "welcome"
+          , mkOptionF
+                (getf False csWelcome)
+                (setf $ \ cs w -> cs { csWelcome = w })
+                False
+          )
+        , ( "folks"
+          , mkOptionF
+                (getf [] $ map unNickname . csFolks)
+                (setf $ \ cs fs -> cs { csFolks = map Nickname fs })
+                []
+          )
+        , ( "email"
+          , mkOptionF
+                (getf "(?)" csEmail)
+                (setf $ \ cs s -> cs { csEmail = s })
+                "(?)"
+          )
+        , ( "browse"
+          , mkOptionF
+                (getf "" $ fromMaybe "" . csBrowse)
+                (setf $ \ cs url ->
+                    if T.null url
+                        then cs { csBrowse = Nothing }
+                        else cs { csBrowse = Just url }
+                )
+                ""
+          )
+        ]
+    , secSubs = M.fromList
+        [ ( "locations"
+          , Section
+                { secOpts = M.fromList $ map (locationOption chan) lls
+                , secSubs = M.empty
+                }
+          )
+        ]
+    }
+    where
+    getf e f = maybe e f . M.lookup chan . stChannels
+    setf f v s =
+        let chans = stChannels s
+            cs = M.lookupDefault defChan chan chans
+            cs' = f cs v
+            chans' = M.insert chan cs' chans
+        in  s { stChannels = chans' }
+
+-- | Add a new channel to state and tree and to be joined from now on. If
+-- already exists, nothing happens.
+addChannel :: Channel -> BotSession ()
+addChannel chan = do
+    selectChannel chan
+    addChannelState chan
+    sets <- getSTree
+    let route = ["channels", unChannel chan]
+    unless (route `memberSub` sets) $ do
+        let sec = chanSec chan []
+            ins = insertSub route sec
+        modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
+
+-- | Add a new location item to a channel's settings and tree. Return 'Nothing'
+-- on success. Otherwise return whether the channel isn't selected ('False') or
+-- the location label already exists ('True').
+addLocalLocation
+    :: Channel
+    -> LocationLabel
+    -> Location
+    -> BotSession (Maybe Bool)
+addLocalLocation chan label location = do
+    sel <- channelSelected chan
+    if sel
+        then do
+            chans <- fmap stChannels getSettings
+            let cs = M.lookupDefault defChan chan chans
+                locs = csLocations cs
+            case M.lookup label locs of
+                Just _  -> return $ Just True
+                Nothing -> do
+                    let locs' = M.insert label location locs
+                        cs' = cs { csLocations = locs' }
+                        chans' = M.insert chan cs' chans
+                    modifySettings $ \ s -> s { stChannels = chans' }
+                    saveBotSettings
+                    let (t, opt) = locationOption chan label
+                        path = ["channels", unChannel chan, "locations", t]
+                        ins = insertOpt path opt
+                    modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
+                    return Nothing
+        else return $ Just False
+
+-- | Remove a channel-specific location from settings and tree. Return whether
+-- success, i.e. whether the location did exist and indeed has been deleted.
+removeLocalLocation :: Channel -> LocationLabel -> BotSession Bool
+removeLocalLocation chan label = do
+    chans <- fmap stChannels getSettings
+    case M.lookup chan chans of
+        Nothing -> return False
+        Just cs ->
+            let locs = csLocations cs
+            in  if M.member label locs
+                    then do
+                        let locs' = M.delete label locs
+                            cs' = cs { csLocations = locs' }
+                            chans' = M.insert chan cs' chans
+                        modifySettings $ \ s -> s { stChannels = chans' }
+                        saveBotSettings
+                        let t = CI.original $ unLocationLabel label
+                            path = ["channels", unChannel chan, "locations", t]
+                            del = deleteOpt path
+                        modifyState $ \ s -> s { bsSTree = del $ bsSTree s }
+                        return True
+                    else return False
diff --git a/src/FunBot/Settings/Sections/DevHosts.hs b/src/FunBot/Settings/Sections/DevHosts.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/Sections/DevHosts.hs
@@ -0,0 +1,85 @@
+{- This file is part of funbot.
+ -
+ - Written in 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Settings.Sections.DevHosts
+    ( devHostOption
+    , addDevHost
+    , removeDevHost
+    )
+where
+
+import Control.Monad (unless, void)
+import Data.Bool (bool)
+import Data.Maybe (fromMaybe)
+import Data.Monoid ((<>))
+import Data.Sequence (Seq, (|>), (><), ViewL (..))
+import Data.Settings.Section
+import Data.Settings.Types
+import FunBot.Settings.MkOption
+import FunBot.Settings.Persist
+import FunBot.Types
+import Network.IRC.Fun.Bot.IrcLog
+import Network.IRC.Fun.Bot.MsgCount
+import Network.IRC.Fun.Bot.Nicks
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Types.Base (Channel (..), Nickname (..))
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.HashMap.Lazy as M
+import qualified Data.Sequence as Q
+import qualified Data.Text as T
+
+devHostOption h@(DevHost t) =
+                    let defl = "(?)"
+                        getl = maybe defl unDevHostLabel . M.lookup h . stDevHosts
+                        setl v sets =
+                            let hosts = stDevHosts sets
+                                hosts' = M.insert h (DevHostLabel v) hosts
+                            in  sets { stDevHosts = hosts' }
+                    in  (CI.original t, mkOptionF getl setl defl)
+
+-- | Add a new dev host to settings and tree. Return whether success, i.e.
+-- whether the dev host didn't exist and indeed a new one has been created.
+addDevHost :: DevHost -> DevHostLabel -> BotSession Bool
+addDevHost host label = do
+    hosts <- fmap stDevHosts getSettings
+    case M.lookup host hosts of
+        Just _  -> return False
+        Nothing -> do
+            let hosts' = M.insert host label hosts
+            modifySettings $ \ s -> s { stDevHosts = hosts' }
+            saveBotSettings
+            let (t, opt) = devHostOption host
+                ins = insertOpt ["dev-hosts", t] opt
+            modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
+            return True
+
+-- | Remove a dev host from settings and tree. Return whether success, i.e.
+-- whether the dev host did exist and indeed has been deleted.
+removeDevHost :: DevHost -> BotSession Bool
+removeDevHost host = do
+    hosts <- fmap stDevHosts getSettings
+    if M.member host hosts
+        then do
+            let hosts' = M.delete host hosts
+            modifySettings $ \ s -> s { stDevHosts = hosts' }
+            saveBotSettings
+            let path = ["dev-hosts", CI.original $ unDevHost host]
+                del = deleteOpt path
+            modifyState $ \ s -> s { bsSTree = del $ bsSTree s }
+            return True
+        else return False
diff --git a/src/FunBot/Settings/Sections/Feeds.hs b/src/FunBot/Settings/Sections/Feeds.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/Sections/Feeds.hs
@@ -0,0 +1,241 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Settings.Sections.Feeds
+    ( feedSec
+    , addFeed
+    , deleteFeed
+    )
+where
+
+import Control.Monad (unless)
+import Control.Monad.IO.Class (liftIO)
+import Data.Bool (bool)
+import Data.Default.Class (def)
+import Data.Maybe (fromMaybe)
+import Data.Monoid ((<>))
+import Data.Sequence (Seq, (|>), (><), ViewL (..))
+import Data.Settings.Section
+import Data.Settings.Types
+import FunBot.Settings.MkOption
+import FunBot.Settings.Persist
+import FunBot.Types
+import Network.IRC.Fun.Bot.IrcLog
+import Network.IRC.Fun.Bot.MsgCount
+import Network.IRC.Fun.Bot.Nicks
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Types.Base (Channel (..), Nickname (..))
+import Web.Feed.Collect hiding (addFeed)
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.HashMap.Lazy as M
+import qualified Data.Sequence as Q
+import qualified Data.Text as T
+import qualified Web.Feed.Collect as F (addFeed)
+
+-- | Create a settings section for a news feed, given its label string
+feedSec :: FeedLabel -> SettingsTree
+feedSec label = Section
+    { secOpts = M.fromList
+        [ ( "url"
+          , mkOptionF'
+                getUrl
+                (\ url s ->
+                    let feeds = stWatchedFeeds s
+                        feed = getFeed s
+                        feed' = feed { nfUrl = url }
+                    in  s { stWatchedFeeds = M.insert label feed' feeds }
+                )
+                defUrl
+                (\ url -> do
+                    cq <- askEnvS feedCmdQueue
+                    active <- fmap getActive getSettings
+                    liftIO $ do
+                        sendCommand cq $ removeFeed labelt
+                        sendCommand cq $ F.addFeed def
+                            { fcLabel  = labelt
+                            , fcUrl    = T.unpack url
+                            , fcActive = active
+                            }
+                )
+          )
+        , ( "active"
+          , mkOptionF'
+                getActive
+                (\ b s ->
+                    let feeds = stWatchedFeeds s
+                        feed = getFeed s
+                        feed' = feed { nfActive = b }
+                    in  s { stWatchedFeeds = M.insert label feed' feeds }
+                )
+                defActive
+                (\ b -> do
+                    cq <- askEnvS feedCmdQueue
+                    liftIO $ sendCommand cq $ setFeedActive labelt b
+                )
+          )
+        , ( "channels"
+          , mkOptionF
+                (map unChannel . getChans)
+                (\ chans s ->
+                    let feeds = stWatchedFeeds s
+                        feed@NewsFeed { nfAnnSpec = spec } = getFeed s
+                        feed' = feed
+                            { nfAnnSpec = spec
+                                { nAnnChannels = map Channel chans
+                                }
+                            }
+                    in  s { stWatchedFeeds = M.insert label feed' feeds }
+                )
+                defChans
+          )
+        ]
+    , secSubs = M.fromList
+        [ ( "show"
+          , Section
+                { secOpts = M.fromList
+                    [ ( "feed-title"
+                      , mkOptionF
+                            (dispFeedTitle . getFields)
+                            (\ b s ->
+                                let feed@NewsFeed { nfAnnSpec = spec } =
+                                        getFeed s
+                                    fieldsOld = nAnnFields spec
+                                    fields = fieldsOld { dispFeedTitle = b }
+                                    feed' = feed
+                                        { nfAnnSpec = spec
+                                            { nAnnFields = fields
+                                            }
+                                        }
+                                in  s   { stWatchedFeeds =
+                                            M.insert label feed' $
+                                            stWatchedFeeds s
+                                        }
+                            )
+                            (dispFeedTitle defFields)
+                      )
+                    , ( "author"
+                      , mkOptionF
+                            (dispAuthor . getFields)
+                            (\ b s ->
+                                let feed@NewsFeed { nfAnnSpec = spec } =
+                                        getFeed s
+                                    fieldsOld = nAnnFields spec
+                                    fields = fieldsOld { dispAuthor = b }
+                                    feed' = feed
+                                        { nfAnnSpec = spec
+                                            { nAnnFields = fields
+                                            }
+                                        }
+                                in  s   { stWatchedFeeds =
+                                            M.insert label feed' $
+                                            stWatchedFeeds s
+                                        }
+                            )
+                            (dispAuthor defFields)
+                      )
+                    , ( "url"
+                      , mkOptionF
+                            (dispUrl . getFields)
+                            (\ b s ->
+                                let feed@NewsFeed { nfAnnSpec = spec } =
+                                        getFeed s
+                                    fieldsOld = nAnnFields spec
+                                    fields = fieldsOld { dispUrl = b }
+                                    feed' = feed
+                                        { nfAnnSpec = spec
+                                            { nAnnFields = fields
+                                            }
+                                        }
+                                in  s   { stWatchedFeeds =
+                                            M.insert label feed' $
+                                            stWatchedFeeds s
+                                        }
+                            )
+                            (dispUrl defFields)
+                      )
+                    ]
+                , secSubs = M.empty
+                }
+          )
+        ]
+    }
+    where
+    labelt = T.unpack $ CI.original $ unFeedLabel label
+    defChans = []
+    defFields = NewsItemFields True True True
+    defSpec = NewsAnnSpec defChans defFields
+    defUrl = ""
+    defActive = False
+    defFeed = NewsFeed defUrl defActive defSpec
+
+    getFeed = M.lookupDefault defFeed label . stWatchedFeeds
+    getUrl = maybe defUrl nfUrl . M.lookup label . stWatchedFeeds
+    getActive = maybe defActive nfActive . M.lookup label . stWatchedFeeds
+    getSpec = maybe defSpec nfAnnSpec . M.lookup label . stWatchedFeeds
+    getChans = nAnnChannels . getSpec
+    getFields = nAnnFields . getSpec
+
+-- | Add a new feed to settings and tree. Return whether success, i.e. whether
+-- the feed didn't exist and indeed a new one has been created.
+addFeed :: FeedLabel -> T.Text -> BotSession Bool
+addFeed label url = do
+    feeds <- fmap stWatchedFeeds getSettings
+    case M.lookup label feeds of
+        Just _  -> return False
+        Nothing -> do
+            -- Update and save settings
+            let feed = NewsFeed
+                    { nfUrl     = url
+                    , nfActive  = True
+                    , nfAnnSpec = defSpec
+                    }
+                feeds' = M.insert label feed feeds
+            modifySettings $ \ s -> s { stWatchedFeeds = feeds' }
+            saveBotSettings
+            -- Update settings UI tree
+            let sec = feedSec label
+                ins = insertSub ["feeds", CI.original $ unFeedLabel label] sec
+            modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
+            -- Send command to update the feed watcher
+            cq <- askEnvS feedCmdQueue
+            liftIO $ sendCommand cq $ F.addFeed $ mkFeed (T.unpack $ CI.original $ unFeedLabel label) (T.unpack url)
+            return True
+    where
+    defChans = []
+    defFields = NewsItemFields True True True
+    defSpec = NewsAnnSpec defChans defFields
+
+-- | Remove a feed from settings and tree. Return whether success, i.e. whether
+-- the feed did exist and indeed has been deleted.
+deleteFeed :: FeedLabel -> BotSession Bool
+deleteFeed label = do
+    feeds <- fmap stWatchedFeeds getSettings
+    if M.member label feeds
+        then do
+            -- Update and save settings
+            let feeds' = M.delete label feeds
+            modifySettings $ \ s -> s { stWatchedFeeds = feeds' }
+            saveBotSettings
+            -- Update settings UI tree
+            let del = deleteSub ["feeds", CI.original $ unFeedLabel label]
+            modifyState $ \ s -> s { bsSTree = del $ bsSTree s }
+            -- Send command to update the feed watcher
+            cq <- askEnvS feedCmdQueue
+            liftIO $ sendCommand cq $ removeFeed $ T.unpack $ CI.original $ unFeedLabel label
+            return True
+        else return False
diff --git a/src/FunBot/Settings/Sections/Locations.hs b/src/FunBot/Settings/Sections/Locations.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/Sections/Locations.hs
@@ -0,0 +1,85 @@
+{- This file is part of funbot.
+ -
+ - Written in 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Settings.Sections.Locations
+    ( locationOption
+    , addLocation
+    , removeLocation
+    )
+where
+
+import Control.Monad (unless, void)
+import Data.Bool (bool)
+import Data.Maybe (fromMaybe)
+import Data.Monoid ((<>))
+import Data.Sequence (Seq, (|>), (><), ViewL (..))
+import Data.Settings.Section
+import Data.Settings.Types
+import FunBot.Settings.MkOption
+import FunBot.Settings.Persist
+import FunBot.Types
+import Network.IRC.Fun.Bot.IrcLog
+import Network.IRC.Fun.Bot.MsgCount
+import Network.IRC.Fun.Bot.Nicks
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Types.Base (Channel (..), Nickname (..))
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.HashMap.Lazy as M
+import qualified Data.Sequence as Q
+import qualified Data.Text as T
+
+locationOption l@(LocationLabel t) =
+                    let defl = "(?)"
+                        getl = maybe defl unLocation . M.lookup l . stLocations
+                        setl v sets =
+                            let locs = stLocations sets
+                                locs' = M.insert l (Location v) locs
+                            in  sets { stLocations = locs' }
+                    in  (CI.original t, mkOptionF getl setl defl)
+
+-- | Add a new location to settings and tree. Return whether success, i.e.
+-- whether the location didn't exist and indeed a new one has been created.
+addLocation :: LocationLabel -> Location -> BotSession Bool
+addLocation label location = do
+    locs <- fmap stLocations getSettings
+    case M.lookup label locs of
+        Just _  -> return False
+        Nothing -> do
+            let locs' = M.insert label location locs
+            modifySettings $ \ s -> s { stLocations = locs' }
+            saveBotSettings
+            let (t, opt) = locationOption label
+                ins = insertOpt ["locations", t] opt
+            modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
+            return True
+
+-- | Remove a location from settings and tree. Return whether success, i.e.
+-- whether the location did exist and indeed has been deleted.
+removeLocation :: LocationLabel -> BotSession Bool
+removeLocation label = do
+    locs <- fmap stLocations getSettings
+    if M.member label locs
+        then do
+            let locs' = M.delete label locs
+            modifySettings $ \ s -> s { stLocations = locs' }
+            saveBotSettings
+            let path = ["locations", CI.original $ unLocationLabel label]
+                del = deleteOpt path
+            modifyState $ \ s -> s { bsSTree = del $ bsSTree s }
+            return True
+        else return False
diff --git a/src/FunBot/Settings/Sections/Repos.hs b/src/FunBot/Settings/Sections/Repos.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/Sections/Repos.hs
@@ -0,0 +1,319 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Settings.Sections.Repos
+    ( hostSection
+    , addRepoAnnSpec
+    , deleteRepoAnnSpec
+    , addRepo
+    , deleteRepo
+    )
+where
+
+import Data.Maybe (fromMaybe)
+import Data.Monoid ((<>))
+import Data.Sequence (Seq, (|>), (><), ViewL (..))
+import Data.Settings.Section
+import Data.Settings.Types
+import FunBot.Settings.MkOption
+import FunBot.Settings.Persist
+import FunBot.Types
+import Network.IRC.Fun.Bot.State (modifyState)
+import Network.IRC.Fun.Types.Base (Channel (..))
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.HashMap.Lazy as M
+import qualified Data.Sequence as Q
+import qualified Data.Text as T
+
+specSection :: DevHostLabel -> RepoSpace -> RepoName -> Int -> SettingsTree
+specSection host space repo pos = Section
+    { secOpts = M.fromList
+        [ ( "channel"
+          , mkopt
+                "set-channel-here"
+                (unChannel . rasChannel)
+                (\ chan spec -> spec { rasChannel = Channel chan })
+          )
+        , ( "branches"
+          , mkopt
+                []
+                (\ spec ->
+                    case rasBranches spec of
+                        Accept l -> map unBranchName l
+                        Reject l -> map unBranchName l
+                )
+                (\ branches spec ->
+                    let bs = case rasBranches spec of
+                                Accept _ -> Accept $ map BranchName branches
+                                Reject _ -> Reject $ map BranchName branches
+                    in  spec { rasBranches = bs }
+                )
+          )
+        , ( "accept"
+          , mkopt
+                False
+                (\ spec ->
+                    case rasBranches spec of
+                        Accept _ -> True
+                        Reject _ -> False
+                )
+                (\ b spec ->
+                    let ctor = if b then Accept else Reject
+                        bs = case rasBranches spec of
+                                Accept l -> ctor l
+                                Reject l -> ctor l
+                    in  spec { rasBranches = bs }
+                )
+          )
+        , ( "all-commits"
+          , mkopt
+                False
+                rasAllCommits
+                (\ b spec -> spec { rasAllCommits = b })
+          )
+        , ( "commits"
+          , mkopt
+                True
+                rasCommits
+                (\ b spec -> spec { rasCommits = b })
+          )
+        , ( "issues"
+          , mkopt
+                True
+                rasIssues
+                (\ b spec -> spec { rasIssues = b })
+          )
+        , ( "merge-requests"
+          , mkopt
+                True
+                rasMergeRequests
+                (\ b spec -> spec { rasMergeRequests = b })
+          )
+        , ( "snippets"
+          , mkopt
+                True
+                rasSnippets
+                (\ b spec -> spec { rasSnippets = b })
+          )
+        , ( "notes"
+          , mkopt
+                True
+                rasNotes
+                (\ b spec -> spec { rasNotes = b })
+          )
+        , ( "new"
+          , mkopt
+                True
+                rasNew
+                (\ b spec -> spec { rasNew = b })
+          )
+        , ( "old"
+          , mkopt
+                True
+                rasOld
+                (\ b spec -> spec { rasOld = b })
+          )
+        , ( "untimed"
+          , mkopt
+                True
+                rasUntimed
+                (\ b spec -> spec { rasUntimed = b })
+          )
+        ]
+    , secSubs = M.empty
+    }
+    where
+    setSpecField f val sets = fromMaybe sets $ do
+        let hosts = stGitAnnChans sets
+        repos <- M.lookup host hosts
+        specs <- M.lookup (space, repo) repos
+        let specs' = Q.adjust (f val) pos specs
+            repos' = M.insert (space, repo) specs' repos
+            hosts' = M.insert host repos' hosts
+        return sets { stGitAnnChans = hosts' }
+    getSpecField defval f sets = fromMaybe defval $ do
+        let hosts = stGitAnnChans sets
+        repos <- M.lookup host hosts
+        specs <- M.lookup (space, repo) repos
+        spec <- if 0 <= pos && pos < Q.length specs
+                    then Just $ Q.index specs pos
+                    else Nothing
+        return $ f spec
+    mkopt defval get set =
+        mkOptionF (getSpecField defval get) (setSpecField set) defval
+
+repoSection
+    :: DevHostLabel
+    -> RepoSpace
+    -> RepoName
+    -> Seq RepoAnnSpec
+    -> (T.Text, SettingsTree)
+repoSection h s r specs =
+    ( CI.original (unRepoSpace s) <> "/" <> CI.original (unRepoName r)
+    , Section
+        { secOpts = M.empty
+        , secSubs = M.fromList $ map mksub [1 .. Q.length specs]
+        }
+    )
+    where
+    mksub i = (T.pack $ show i, specSection h s r (i - 1))
+
+hostSection
+    :: DevHostLabel
+    -> M.HashMap (RepoSpace, RepoName) (Seq RepoAnnSpec)
+    -> SettingsTree
+hostSection host repos = Section
+    { secOpts = M.empty
+    , secSubs = M.fromList $ map (uncurry f) $ M.toList repos
+    }
+    where
+    f (space, repo) specs = repoSection host space repo specs
+
+mkDefSpec :: Channel -> RepoAnnSpec
+mkDefSpec chan = RepoAnnSpec
+    { rasChannel       = chan
+    , rasBranches      = Reject []
+    , rasAllCommits    = False
+    , rasCommits       = True
+    , rasIssues        = True
+    , rasMergeRequests = True
+    , rasSnippets      = True
+    , rasNotes         = True
+    , rasNew           = True
+    , rasOld           = True
+    , rasUntimed       = True
+    }
+
+-- | Append a new repo ann spec to the settings and a matching tree under the
+-- repo section. Return whether succeeded.
+addRepoAnnSpec
+    :: DevHostLabel
+    -> RepoSpace
+    -> RepoName
+    -> Channel
+    -> BotSession Bool
+addRepoAnnSpec host space repo chan = do
+    hosts <- fmap stGitAnnChans getSettings
+    case M.lookup host hosts of
+        Just repos ->
+            case M.lookup (space, repo) repos of
+                Just specs -> do
+                    let specs' = specs |> defSpec
+                        repos' = M.insert (space, repo) specs' repos
+                        hosts' = M.insert host repos' hosts
+                    modifySettings $ \ s -> s { stGitAnnChans = hosts' }
+                    saveBotSettings
+                    let (t, sec) = repoSection host space repo specs'
+                        ins = insertSub ["repos", unDevHostLabel host, t] sec
+                    modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
+                    return True
+                Nothing -> return False
+        Nothing -> return False
+    where
+    defSpec = mkDefSpec chan
+
+-- | Remove a spec from a repo. Return 'Nothing' on success. Otherwise return
+-- whether the error was repo not found ('False') or index too big ('True').
+-- The position given is 0-based.
+deleteRepoAnnSpec
+    :: DevHostLabel
+    -> RepoSpace
+    -> RepoName
+    -> Int
+    -> BotSession (Maybe Bool)
+deleteRepoAnnSpec host space repo pos = do
+    hosts <- fmap stGitAnnChans getSettings
+    case M.lookup host hosts of
+        Just repos ->
+            case M.lookup (space, repo) repos of
+                Just specs ->
+                    let (u, v) = Q.splitAt pos specs
+                    in  case Q.viewl v of
+                            EmptyL -> return $ Just True
+                            s :< r -> do
+                                let specs' = u >< r
+                                    repos' =
+                                        M.insert (space, repo) specs' repos
+                                    hosts' = M.insert host repos' hosts
+                                modifySettings $
+                                    \ s -> s { stGitAnnChans = hosts' }
+                                saveBotSettings
+                                let (t, sec) =
+                                        repoSection host space repo specs'
+                                    ins =
+                                        insertSub
+                                            ["repos", unDevHostLabel host, t]
+                                            sec
+                                modifyState $
+                                    \ s -> s { bsSTree = ins $ bsSTree s }
+                                return Nothing
+                Nothing -> return $ Just False
+        Nothing -> return $ Just False
+
+-- | Add a new repo to settings and tree. Return 'Nothing' on success.
+-- Otherwise return whether the host doesn't exist ('False') or the repo
+-- already exists ('True').
+addRepo
+    :: DevHostLabel
+    -> RepoSpace
+    -> RepoName
+    -> Channel
+    -> BotSession (Maybe Bool)
+addRepo host space repo chan = do
+    hosts <- fmap stGitAnnChans getSettings
+    case M.lookup host hosts of
+        Nothing    -> return $ Just False
+        Just repos ->
+            case M.lookup (space, repo) repos of
+                Just _  -> return $ Just True
+                Nothing -> do
+                    let specs = Q.singleton defSpec
+                        repos' =
+                            M.insert (space, repo) specs repos
+                        hosts' = M.insert host repos' hosts
+                    modifySettings $ \ s -> s { stGitAnnChans = hosts' }
+                    saveBotSettings
+                    let (t, sec) = repoSection host space repo specs
+                        ins = insertSub ["repos", unDevHostLabel host, t] sec
+                    modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
+                    return Nothing
+    where
+    defSpec = mkDefSpec chan
+
+-- | Remove a repo from settings and tree. Return whether success, i.e. whether
+-- the repo did exist and indeed has been deleted.
+deleteRepo :: DevHostLabel -> RepoSpace -> RepoName -> BotSession Bool
+deleteRepo host space repo = do
+    hosts <- fmap stGitAnnChans getSettings
+    case M.lookup host hosts of
+        Just repos ->
+            if M.member (space, repo) repos
+                then do
+                    let repos' = M.delete (space, repo) repos
+                        hosts' = M.insert host repos' hosts
+                    modifySettings $ \ s -> s { stGitAnnChans = hosts' }
+                    saveBotSettings
+                    let name =
+                            CI.original (unRepoSpace space) <>
+                            "/" <>
+                            CI.original (unRepoName repo)
+                        del = deleteSub ["repos", unDevHostLabel host, name]
+                    modifyState $ \ s -> s { bsSTree = del $ bsSTree s }
+                    return True
+                else return False
+        Nothing    -> return False
diff --git a/src/FunBot/Settings/Sections/Shortcuts.hs b/src/FunBot/Settings/Sections/Shortcuts.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Settings/Sections/Shortcuts.hs
@@ -0,0 +1,121 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Settings.Sections.Shortcuts
+    ( shortcutSec
+    , addShortcut
+    , deleteShortcut
+    )
+where
+
+import Control.Monad (unless)
+import Data.Bool (bool)
+import Data.Maybe (fromMaybe)
+import Data.Monoid ((<>))
+import Data.Sequence (Seq, (|>), (><), ViewL (..))
+import Data.Settings.Section
+import Data.Settings.Types
+import FunBot.Settings.MkOption
+import FunBot.Settings.Persist
+import FunBot.Types
+import Network.IRC.Fun.Bot.IrcLog
+import Network.IRC.Fun.Bot.MsgCount
+import Network.IRC.Fun.Bot.Nicks
+import Network.IRC.Fun.Bot.State
+import Network.IRC.Fun.Types.Base (Channel (..), Nickname (..))
+
+import qualified Data.CaseInsensitive as CI
+import qualified Data.HashMap.Lazy as M
+import qualified Data.Sequence as Q
+import qualified Data.Text as T
+
+-- | Create a settings section for a shortcut, given its label string
+shortcutSec :: ShortcutLabel -> SettingsTree
+shortcutSec label = Section
+    { secOpts = M.fromList
+        [ ( "prefix"
+          , mkOptionF
+                (getf shPrefix)
+                (setf $ \ cut prefix -> cut { shPrefix = prefix })
+                ""
+          )
+        , ( "before"
+          , mkOptionF
+                (getf shBefore)
+                (setf $ \ cut before -> cut { shBefore = before })
+                ""
+          )
+        , ( "after"
+          , mkOptionF
+                (getf shAfter)
+                (setf $ \ cut after -> cut { shAfter = after })
+                ""
+          )
+        , ( "channels"
+          , mkOptionF
+                (getl $ map unChannel . shChannels)
+                (setf $ \ cut chans -> cut { shChannels = map Channel chans })
+                []
+          )
+        ]
+    , secSubs = M.empty
+    }
+    where
+    err = "ERROR not found"
+    getf f = maybe err f . M.lookup label . stShortcuts
+    getl f = maybe []  f . M.lookup label . stShortcuts
+    setf f v s =
+        let cuts = stShortcuts s
+        in  case M.lookup label cuts of
+                Nothing  -> s
+                Just cut ->
+                    let cut' = f cut v
+                        cuts' = M.insert label cut' cuts
+                    in  s { stShortcuts = cuts' }
+
+-- | Add a new shortcut to settings and tree. Return whether success, i.e.
+-- whether the shortcut didn't exist and indeed a new one has been created.
+addShortcut :: ShortcutLabel -> Channel -> BotSession Bool
+addShortcut label chan = do
+    cuts <- fmap stShortcuts getSettings
+    case M.lookup label cuts of
+        Just _  -> return False
+        Nothing -> do
+            let cuts' = M.insert label defCut cuts
+            modifySettings $ \ s -> s { stShortcuts = cuts' }
+            saveBotSettings
+            let sec = shortcutSec label
+                ins = insertSub ["shortcuts", CI.foldedCase $ unShortcutLabel label] sec
+            modifyState $ \ s -> s { bsSTree = ins $ bsSTree s }
+            return True
+    where
+    defCut = Shortcut "PrEfIx" "http://BeFoRe.org/" "/AfTeR.html" [chan]
+
+-- | Remove a shortcut from settings and tree. Return whether success, i.e.
+-- whether the shortcut did exist and indeed has been deleted.
+deleteShortcut :: ShortcutLabel -> BotSession Bool
+deleteShortcut label = do
+    cuts <- fmap stShortcuts getSettings
+    if M.member label cuts
+        then do
+            let cuts' = M.delete label cuts
+            modifySettings $ \ s -> s { stShortcuts = cuts' }
+            saveBotSettings
+            let del = deleteSub ["shortcuts", CI.foldedCase $ unShortcutLabel label]
+            modifyState $ \ s -> s { bsSTree = del $ bsSTree s }
+            return True
+        else return False
diff --git a/src/FunBot/Sources/FeedWatcher.hs b/src/FunBot/Sources/FeedWatcher.hs
--- a/src/FunBot/Sources/FeedWatcher.hs
+++ b/src/FunBot/Sources/FeedWatcher.hs
@@ -13,6 +13,8 @@
  - <http://creativecommons.org/publicdomain/zero/1.0/>.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module FunBot.Sources.FeedWatcher
     ( feedWatcherSource
     )
@@ -21,27 +23,32 @@
 import Data.Maybe (fromMaybe)
 import Data.Default.Class (def)
 import Data.Time.Interval (time)
---import Data.Time.Units
 import FunBot.Config (feedVisitInterval)
 import FunBot.ExtEvents (ExtEvent (NewsEvent), NewsItem (..))
 import FunBot.Types
 import Network.IRC.Fun.Bot.Logger
 import Text.Feed.Query
+import Text.Feed.Types (Feed, Item)
 import Web.Feed.Collect
 
+import qualified Data.CaseInsensitive as CI
+import qualified Data.Text as T
 import qualified Data.HashMap.Lazy as M
 
-makeItem label ftitle item = NewsEvent $ NewsItem
-    { itemFeedLabel = label
-    , itemFeedTitle = Just ftitle
-    , itemTitle     = fromMaybe "(no title)" $ getItemTitle item
-    , itemAuthor    = getItemAuthor item
-    , itemUrl       = getItemLink item
+makeItem :: Label -> String -> Item -> ExtEvent
+makeItem label ftitle item = NewsEvent NewsItem
+    { itemFeedLabel = T.pack label
+    , itemFeedTitle = Just $ T.pack ftitle
+    , itemTitle     = fromMaybe "(no title)" $ fmap T.pack $ getItemTitle item
+    , itemAuthor    = fmap T.pack $ getItemAuthor item
+    , itemUrl       = fmap T.pack $ getItemLink item
     }
 
+collect :: (ExtEvent -> IO ()) -> Label -> Url -> Feed -> Item -> IO ()
 collect push label _url feed item =
     push $ makeItem label (getFeedTitle feed) item
 
+logError :: Logger -> Label -> Error -> IO ()
 logError logger label err = logLine logger $ label ++ " : " ++ show err
 
 feedWatcherSource :: FilePath -> BotState -> ExtEventSource
@@ -50,8 +57,8 @@
     let cq = feedCmdQueue env
         pairs = M.toList $ stWatchedFeeds $ bsSettings state
         mkfeed (label, nf) = def
-            { fcLabel  = label
-            , fcUrl    = nfUrl nf
+            { fcLabel  = T.unpack $ CI.original$ unFeedLabel $ label
+            , fcUrl    = T.unpack $ nfUrl nf
             , fcActive = nfActive nf
             }
         feeds = map mkfeed pairs
diff --git a/src/FunBot/Sources/WebListener.hs b/src/FunBot/Sources/WebListener.hs
--- a/src/FunBot/Sources/WebListener.hs
+++ b/src/FunBot/Sources/WebListener.hs
@@ -18,25 +18,46 @@
     )
 where
 
+import Data.ByteString.Lazy (empty)
 import FunBot.Sources.WebListener.Client (dispatchClient)
 import FunBot.Sources.WebListener.GitLab (dispatchGitLab)
 import FunBot.Sources.WebListener.Gogs (dispatchGogs)
 import FunBot.Types
-import Network.HTTP (Request (..), RequestMethod (..))
+import Network.HTTP (Request (..), RequestMethod (..), Response (..))
 import Network.HTTP.Listen (run)
+import Network.IRC.Fun.Bot.Logger
 import Network.IRC.Fun.Bot.State (askEnvS)
 import Network.URI (uriPath)
 
-listener push pushMany request = do
+resp = Response
+    { rspCode    = (2, 0, 0)
+    , rspReason  = "OK"
+    , rspHeaders = []
+    , rspBody    = empty
+    }
+
+listener dlogger elogger push pushMany request = do
+    logLine dlogger $ show request
+    logLine dlogger $ show $ rqBody request
     case (uriPath $ rqURI request, rqMethod request) of
-        ("/gogs", POST)   -> dispatchGogs push pushMany request
-        ("/gitlab", POST) -> dispatchGitLab push pushMany request
-        ("/client", POST) -> dispatchClient push pushMany request
+        ("/gogs", POST)   -> do
+            dispatchGogs push pushMany request
+            return Nothing
+        ("/gitlab", POST) -> do
+            dispatchGitLab push pushMany request
+            return $ Just resp
+        ("/client", POST) -> do
+            dispatchClient push pushMany request
+            return Nothing
         _ -> do
-            putStrLn "Web listener source: Unrecognized request received:"
-            print request
-    return Nothing
+            logLine elogger "Web listener source: \
+                            \Unrecognized request received:"
+            logLine elogger $ show request
+            return Nothing
 
-webListenerSource _config env push pushMany _mkLogger = do
+webListenerSource :: FilePath -> ExtEventSource
+webListenerSource elogfile _config env push pushMany mkLogger = do
+    elogger <- mkLogger elogfile
+    dlogger <- mkLogger "state/web-access.log"
     putStrLn "Bot: Web listener source listening to HTTP requests"
-    run (webHookSourcePort env) $ listener push pushMany
+    run (webHookSourcePort env) $ listener dlogger elogger push pushMany
diff --git a/src/FunBot/Sources/WebListener/GitLab.hs b/src/FunBot/Sources/WebListener/GitLab.hs
--- a/src/FunBot/Sources/WebListener/GitLab.hs
+++ b/src/FunBot/Sources/WebListener/GitLab.hs
@@ -22,7 +22,10 @@
 
 import Control.Monad (when)
 import Data.Maybe (fromMaybe)
+import Data.Monoid ((<>))
 import FunBot.ExtEvents
+import Formatting
+import FunBot.Sources.WebListener.Util
 import Network.HTTP (Request (..))
 import Network.URI
 
@@ -31,68 +34,123 @@
 import qualified Data.Text as T
 import qualified Web.Hook.GitLab as G
 
-refToBranch :: T.Text -> T.Text
-refToBranch ref = fromMaybe ref $ T.stripPrefix "refs/heads/" ref
-
-refToTag :: T.Text -> T.Text
-refToTag ref = fromMaybe ref $ T.stripPrefix "refs/tags/" ref
-
-nl c = c == '\n' || c == '\r'
+makeCommit :: G.Commit -> Commit
+makeCommit commit = Commit
+    { commitAuthor   = G.authorName $ G.commitAuthor commit
+    , commitTitle    = takeLine $ G.commitMessage commit
+    , commitUrl      = G.commitUrl commit
+    , commitAdded    = G.commitAdded commit
+    , commitModified = G.commitModified commit
+    , commitRemoved  = G.commitRemoved commit
+    }
 
-urlToOwner url =
-    case parseAbsoluteURI url of
-        Just (URI { uriPath = _ : p }) -> takeWhile (/= '/') p
-        _                              -> ""
+makeRepo :: G.Repository -> Repository
+makeRepo repo = Repository
+    { repoName  = G.repoName repo
+    , repoSpace = uriSpace uri
+    , repoHost  = uriHost uri
+    }
+    where
+    uri = parseURI $ T.unpack $ G.repoHomepage repo
 
-makeCommit commit =
-    let author = T.unpack $ G.authorName $ G.commitAuthor commit
-        msg    = T.unpack $ T.takeWhile (not . nl) $ G.commitMessage commit
-        url    = T.unpack $ G.commitUrl commit
-    in  Commit author msg url
+makePush :: G.Push -> ExtEvent
+makePush push = GitPushEvent ProjectObject
+    { poRepo = makeRepo $ G.pushRepository push
+    , poObj  = Push
+        { pushBranch  = refToBranch $ G.pushRef push
+        , pushCommits = map makeCommit $ G.pushCommits push
+        }
+    }
 
-makeBranch ref repo =
-    let branch = T.unpack $ refToBranch ref
-        repo'  = T.unpack $ G.repoName repo
-        owner  = urlToOwner $ T.unpack $ G.repoHomepage repo
-    in  Branch branch repo' owner
+makeTag :: T.Text -> G.Repository -> T.Text -> ExtEvent
+makeTag ref repo user = GitTagEvent $ ProjectObject
+    { poRepo = makeRepo repo
+    , poObj  = Tag
+        { tagAuthor = user
+        , tagRef    = refToTag ref
+        }
+    }
 
-makeTag ref repo user =
-    let user'  = T.unpack user
-        ref'   = T.unpack $ refToTag ref
-        repo'  = T.unpack $ G.repoName repo
-        owner  = urlToOwner $ T.unpack $ G.repoHomepage repo
-    in  GitTagEvent $ Tag user' ref' repo' owner
+makeIssue :: G.IssueEvent -> ExtEvent
+makeIssue ie = IssueEvent ProjectObject
+    { poRepo = makeRepo $ G.ieRepo ie
+    , poObj  = Issue
+        { issueAuthor = G.userName $ G.ieUser ie
+        , issueId     = G.issueId i
+        , issueTitle  = takeLine $ G.issueTitle i
+        , issueUrl    = G.issueUrl i
+        , issueAction = G.ieAction ie
+        }
+    }
+    where
+    i = G.ieIssue ie
 
-makeMR mre =
-    let mr     = G.mreRequest mre
-        author = T.unpack $ G.userName $ G.mreUser mre
-        iid    = G.mrId mr
-        repo   = T.unpack $ G.mepName $ G.mrTarget mr
-        owner  = T.unpack $ G.mepNamespace $ G.mrTarget mr
-        title  = T.unpack $ T.takeWhile (not . nl) $ G.mrTitle mr -- to be safe
-        url    = T.unpack $ G.mrUrl mr
-        action = T.unpack $ G.mreAction mre
-    in  MergeRequestEvent $ MergeRequest author iid repo owner title url action
+makeMR :: G.MergeRequestEvent -> ExtEvent
+makeMR mre = MergeRequestEvent ProjectObject
+    { poRepo = Repository
+        { repoName  = G.mepName $ G.mrTarget mr
+        , repoSpace = G.mepNamespace $ G.mrTarget mr
+        , repoHost  = uriHost $ parseURI $ T.unpack $ G.mepWebUrl target
+        }
+    , poObj  = MergeRequest
+        { mrAuthor = G.userName $ G.mreUser mre
+        , mrId     = G.mrId mr
+        , mrTitle  = takeLine $ G.mrTitle mr
+        , mrUrl    = G.mrUrl mr
+        , mrAction = G.mreAction mre
+        }
+    }
+    where
+    mr     = G.mreRequest mre
+    target = G.mrTarget mr
 
-dispatchPush push _pushMany p =
-    let commits = map makeCommit $ G.pushCommits p
-        branch = makeBranch (G.pushRef p) (G.pushRepository p)
-    in  push $ GitPushEvent $ Push branch commits
+shorten n t =
+    if t `T.compareLength` n == GT
+        then T.take n t <> "[…]"
+        else t
 
-dispatchTag push pushMany p =
-    push $ makeTag (G.pushRef p) (G.pushRepository p) (G.pushUserName p)
+showNoteTarget :: G.NoteTarget -> T.Text
+showNoteTarget (G.NTCommit c) =
+    sformat
+        ("commit " % stext % " (“" % stext % "”)")
+        (T.take 8 $ G.commitId c)
+        (shorten 40 $ takeLine $ G.commitMessage c)
+showNoteTarget (G.NTIssue i) =
+    sformat ("issue #" % int) (G.issueId i)
+showNoteTarget (G.NTMergeRequest mr) =
+    sformat ("MR #" % int) (G.mrId mr)
+showNoteTarget (G.NTSnippet s) =
+    sformat ("snippet #" % int) (G.snippetId s)
 
-dispatchMR push pushMany e = do
-    let mre@(MergeRequestEvent mr) = makeMR e
-    when (mrAction mr /= "update") $ push mre
+makeNote :: G.NoteEvent -> ExtEvent
+makeNote ne = NoteEvent ProjectObject
+    { poRepo = makeRepo $ G.neRepo ne
+    , poObj  = Note
+        { noteAuthor  = G.userName $ G.neUser ne
+        , noteContent = shorten 200 $ takeLine $ G.noteNote n
+        , noteTarget  = showNoteTarget $ G.neTarget ne
+        , noteUrl     = G.noteUrl n
+        }
+    }
+    where
+    n = G.neNote ne
+    maxlen = 200
 
-dispatchGitLab push pushMany request =
+dispatchGitLab push _pushMany request =
     case G.parse $ rqBody request of
         Left e -> do
             putStr "Web hook source: GitLab: "
             print e
             BC.putStrLn $ rqBody request
-        Right (G.EventPush e)         -> dispatchPush push pushMany e
-        Right (G.EventPushTag e)      -> dispatchTag push pushMany e
-        Right (G.EventIssue e)        -> putStrLn ">>> GitLab Issue"
-        Right (G.EventMergeRequest e) -> dispatchMR push pushMany e
+        Right (G.EventPush e) ->
+            push $ makePush e
+        Right (G.EventPushTag e) ->
+            push $
+            makeTag (G.pushRef e) (G.pushRepository e) (G.pushUserName e)
+        Right (G.EventIssue e) ->
+            push $ makeIssue e
+        Right (G.EventMergeRequest e) -> do
+            let mre@(MergeRequestEvent (ProjectObject _ mr)) = makeMR e
+            when (mrAction mr /= "update") $ push mre
+        Right (G.EventNote e) ->
+            push $ makeNote e
diff --git a/src/FunBot/Sources/WebListener/Gogs.hs b/src/FunBot/Sources/WebListener/Gogs.hs
--- a/src/FunBot/Sources/WebListener/Gogs.hs
+++ b/src/FunBot/Sources/WebListener/Gogs.hs
@@ -22,32 +22,41 @@
 
 import Data.Maybe (fromMaybe)
 import FunBot.ExtEvents
+import FunBot.Sources.WebListener.Util
 import Network.HTTP (Request (..))
+import Network.URI
 
 import qualified Data.Text as T
 import qualified Web.Hook.Gogs as G
 
-refToBranch :: T.Text -> T.Text
-refToBranch ref = fromMaybe ref $ T.stripPrefix "refs/heads/" ref
-
-nl c = c == '\n' || c == '\r'
-
-makeCommit commit =
-    let author = T.unpack $ G.userName $ G.commitAuthor commit
-        msg    = T.unpack $ T.takeWhile (not . nl) $ G.commitMessage commit
-        url    = T.unpack $ G.commitUrl commit
-    in  Commit author msg url
+makeCommit :: G.Commit -> Commit
+makeCommit commit = Commit
+    { commitAuthor   = G.userName $ G.commitAuthor commit
+    , commitTitle    = takeLine $ G.commitMessage commit
+    , commitUrl      = G.commitUrl commit
+    , commitAdded    = []
+    , commitModified = []
+    , commitRemoved  = []
+    }
 
-makeBranch ref repo =
-    let branch = T.unpack $ refToBranch ref
-        repo'  = T.unpack $ G.repoName repo
-        owner  = T.unpack $ G.userUsername $ G.repoOwner repo
-    in  Branch branch repo' owner
+makeRepo :: G.Repository -> Repository
+makeRepo repo = Repository
+    { repoName  = G.repoName repo
+    , repoSpace = G.userUsername $ G.repoOwner repo
+    , repoHost  = fromMaybe "UNKNOWN" $ do
+        uri <- parseURI $ T.unpack $ G.repoUrl repo
+        auth <- uriAuthority uri
+        return $ T.pack $ uriRegName auth
+    }
 
-fromGogs push =
-    let commits = map makeCommit $ G.pushCommits push
-        branch = makeBranch (G.pushRef push) (G.pushRepository push)
-    in  GitPushEvent $ Push branch (reverse commits)
+fromGogs :: G.Push -> ExtEvent
+fromGogs push = GitPushEvent ProjectObject
+    { poRepo = makeRepo $ G.pushRepository push
+    , poObj  = Push
+        { pushBranch  = refToBranch $ G.pushRef push
+        , pushCommits = reverse $ map makeCommit $ G.pushCommits push
+        }
+    }
 
 dispatchGogs push _pushMany request =
     case G.parse $ rqBody request of
diff --git a/src/FunBot/Sources/WebListener/Util.hs b/src/FunBot/Sources/WebListener/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/FunBot/Sources/WebListener/Util.hs
@@ -0,0 +1,53 @@
+{- This file is part of funbot.
+ -
+ - Written in 2015 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module FunBot.Sources.WebListener.Util
+    ( refToBranch
+    , refToTag
+    , nl
+    , takeLine
+    , uriSpace
+    , uriHost
+    )
+where
+
+import Data.Maybe (fromMaybe)
+import Network.URI
+
+import qualified Data.Text as T
+
+refToBranch :: T.Text -> T.Text
+refToBranch ref = fromMaybe ref $ T.stripPrefix "refs/heads/" ref
+
+refToTag :: T.Text -> T.Text
+refToTag ref = fromMaybe ref $ T.stripPrefix "refs/tags/" ref
+
+nl :: Char -> Bool
+nl c = c == '\n' || c == '\r'
+
+takeLine :: T.Text -> T.Text
+takeLine = T.takeWhile $ not . nl
+
+uriSpace :: Maybe URI -> T.Text
+uriSpace (Just URI { uriPath = _ : p }) = T.pack $ takeWhile (/= '/') p
+uriSpace _                              = "UNKNOWN"
+
+uriHost :: Maybe URI -> T.Text
+uriHost mu = fromMaybe "UNKNOWN" $ do
+    uri <- mu
+    auth <- uriAuthority uri
+    return $ T.pack $ uriRegName auth
diff --git a/src/FunBot/Types.hs b/src/FunBot/Types.hs
--- a/src/FunBot/Types.hs
+++ b/src/FunBot/Types.hs
@@ -1,6 +1,6 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
  -
  - ♡ Copying is an act of love. Please copy, reuse and share.
  -
@@ -13,10 +13,22 @@
  - <http://creativecommons.org/publicdomain/zero/1.0/>.
  -}
 
+-- For deriving trivial no-op Hashable instances for newtypes
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+
 module FunBot.Types
-    ( Filter (..)
+    ( RepoName (..)
+    , RepoSpace (..)
+    , BranchName (..)
+    , DevHostLabel (..)
+    , DevHost (..)
+    , FeedLabel (..)
+    , ShortcutLabel (..)
+    , LocationLabel (..)
+    , Location (..)
+    , Filter (..)
     , BranchFilter
-    , PushAnnSpec (..)
+    , RepoAnnSpec (..)
     , NewsItemFields (..)
     , NewsAnnSpec (..)
     , NewsFeed (..)
@@ -33,33 +45,100 @@
     , BotSession
     , ExtEventSource
     , ExtEventHandler
+    , Respond
+    , BotCmd
     )
 where
 
 import Control.Concurrent.Chan (Chan)
+import Data.Aeson (FromJSON (..), ToJSON (..))
+import Data.CaseInsensitive (CI)
+import Data.Functor ((<$>))
+import Data.Hashable (Hashable)
 import Data.HashMap.Lazy (HashMap)
 import Data.HashSet (HashSet)
+import Data.Sequence (Seq)
 import Data.Settings.Types (Section (..), Option (..))
+import Data.Text (Text)
 import Data.Time.Clock (UTCTime)
 import FunBot.ExtEvents (ExtEvent)
-import Network.IRC.Fun.Bot.Types (Session, EventSource, EventHandler)
+import Network.IRC.Fun.Bot.Types (Session, EventSource, EventHandler, Command)
+import Network.IRC.Fun.Types.Base (Nickname, Channel, MsgContent)
 import Web.Feed.Collect (CommandQueue)
 
+import qualified Data.CaseInsensitive as CI
+
+instance (FromJSON s, CI.FoldCase s) => FromJSON (CI s) where
+    parseJSON v = CI.mk <$> parseJSON v
+
+instance ToJSON s => ToJSON (CI s) where
+    toJSON = toJSON . CI.original
+
+-- | A version control repository name
+newtype RepoName = RepoName { unRepoName :: CI Text }
+    deriving (Eq, Hashable)
+
+-- | A repo hosting service repo namespace, e.g. user or team name
+newtype RepoSpace = RepoSpace { unRepoSpace :: CI Text }
+    deriving (Eq, Hashable)
+
+-- | A version control repository branch name
+newtype BranchName = BranchName { unBranchName :: Text }
+    deriving (Eq, FromJSON, ToJSON)
+
+-- | A repo hosting service host label
+newtype DevHostLabel = DevHostLabel { unDevHostLabel :: Text }
+    deriving (Eq, Hashable)
+
+-- | A repo hosting service DNS name
+newtype DevHost = DevHost { unDevHost :: CI Text } deriving (Eq, Hashable)
+
+-- | TODO
+newtype FeedLabel = FeedLabel { unFeedLabel :: CI Text }
+    deriving (Eq, Hashable)
+
+-- | TODO
+newtype ShortcutLabel = ShortcutLabel { unShortcutLabel :: CI Text }
+    deriving (Eq, Hashable)
+
+-- | TODO
+newtype LocationLabel = LocationLabel { unLocationLabel :: CI Text }
+    deriving (Eq, Hashable)
+
+-- | TODO
+newtype Location = Location { unLocation :: Text }
+
 -- | Generic item filter
 data Filter a = Accept [a] | Reject [a]
 
 -- | Chooser for repo branches whose commits should be announced to IRC
-type BranchFilter = Filter String
+type BranchFilter = Filter BranchName
 
--- | Configuration for announcing a git repo's commits to a specific channel
-data PushAnnSpec = PushAnnSpec
+-- | Configuration for announcing a git repo's events to a specific channel
+data RepoAnnSpec = RepoAnnSpec
     { -- | IRC channel into which to announce
-      pAnnChannel    :: String
-      -- Branch filter to choose which branches to announce
-    , pAnnBranches   :: BranchFilter
-      -- Whether to report all commits in a push ('True') or shorten long
+      rasChannel       :: Channel
+      -- | Branch filter to choose which branches to announce
+    , rasBranches      :: BranchFilter
+      -- | Whether to report all commits in a push ('True') or shorten long
       -- pushes to avoid channel spam ('False').
-    , pAnnAllCommits :: Bool
+    , rasAllCommits    :: Bool
+      -- | Whether to announce commits and tags.
+    , rasCommits       :: Bool
+      -- | Whether to announce issues.
+    , rasIssues        :: Bool
+      -- | Whether to announce merge requests.
+    , rasMergeRequests :: Bool
+      -- | Whether to announce snippets.
+    , rasSnippets      :: Bool
+      -- | Whether to announce notes (comments).
+    , rasNotes         :: Bool
+      -- | Whether to announce recent fresh events.
+    , rasNew           :: Bool
+      -- | Whether to announce older events sent retroactively.
+    , rasOld           :: Bool
+      -- | Wherher to announce events whose time isn't specified.
+    , rasUntimed       :: Bool
     }
 
 -- | Pick news item fields to display
@@ -72,7 +151,7 @@
 -- | Configuration for announcing news items
 data NewsAnnSpec = NewsAnnSpec
     { -- | IRC channels into which to announce
-      nAnnChannels :: [String]
+      nAnnChannels :: [Channel]
       -- | Filter for picking news item fields to display or hide
     , nAnnFields   :: NewsItemFields
     }
@@ -80,7 +159,7 @@
 -- | A web news feed from which the bot can read and announce new items
 data NewsFeed = NewsFeed
     { -- | The feed URL
-      nfUrl     :: String
+      nfUrl     :: Text
       -- | Whether the feed watcher is watching this feed
     , nfActive  :: Bool
       -- | Item announcement details
@@ -96,11 +175,11 @@
       -- stored in bot state, so you probably don't need this field directly.
     , saveSettings      :: Settings -> IO ()
       -- | Similarly for memos.
-    , saveMemos         :: HashMap String [Memo] -> IO ()
+    , saveMemos         :: HashMap Nickname [Memo] -> IO ()
       -- | Similarly for user options.
-    , saveUserOpts      :: HashMap String UserOptions -> IO ()
+    , saveUserOpts      :: HashMap Nickname UserOptions -> IO ()
       -- | Similarly for known nicks.
-    , saveNicks         :: HashMap String (HashSet String) -> IO ()
+    , saveNicks         :: HashMap Channel (HashSet Nickname) -> IO ()
       -- | Filename for logging feed listener errors
     , feedErrorLogFile  :: FilePath
       -- | Command queue for controlling the news feed watcher source
@@ -115,39 +194,59 @@
     { -- | String by which the shortcut is detected. For example, if you'd like
       -- \"SD-258\" to refer to the URL of ticket #258, then you should set
       -- the prefix to \"SD-\".
-      shPrefix   :: String
+      shPrefix   :: Text
       -- | The generated longer form is a concatenation of this field, the
       -- shortcut string (without the prefix) and 'shAfter'.
-    , shBefore   :: String
+    , shBefore   :: Text
       -- | The generated longer form is a concatenation of 'shBefore', the
       -- shortcut string (without the prefix) and this field.
-    , shAfter    :: String
+    , shAfter    :: Text
       -- | The channels in which this shortcut should be applied.
-    , shChannels :: [String]
+    , shChannels :: [Channel]
     }
 
 -- | Per-channel settings
 data ChanSettings = ChanSettings
     { -- | Whether to display URL titles (the default is yes).
-      csSayTitles :: Bool
+      csSayTitles  :: Bool
       -- | Whether to welcome new users when the channel is quiet.
-    , csWelcome   :: Bool
+    , csWelcome    :: Bool
       -- | Nicks to mention in the welcome message.
-    , csFolks     :: [String]
+    , csFolks      :: [Nickname]
       -- | Email address for async discussions.
-    , csEmail     :: String
+    , csEmail      :: Text
+      -- | Generic key-value mapping intended to refer to URLs by short labels.
+    , csLocations  :: HashMap LocationLabel Location
+      -- | Users who can ask the bot to send an arbitrary message in the
+      -- channel. Can be useful but also dangerous, manage with care.
+    , csPuppeteers :: HashSet Nickname
+      -- | URL of an IRCBrowse instance for the specific channel.
+    , csBrowse     :: Maybe Text
     }
 
 -- | User-modifiable bot behavior settings
 data Settings = Settings
-    { -- | Maps a Git repo name+owner to annoucement details
-      stGitAnnChans  :: HashMap (String, String) [PushAnnSpec]
+    { -- | Maps a host label to Git repo space+name to annoucement details
+      stGitAnnChans  :: HashMap
+                            DevHostLabel
+                            (HashMap (RepoSpace, RepoName) (Seq RepoAnnSpec))
       -- | Maps a feed label to its URL and announcement details
-    , stWatchedFeeds :: HashMap String NewsFeed
+    , stWatchedFeeds :: HashMap FeedLabel NewsFeed
       -- | Maps a shortcut label to its spec
-    , stShortcuts    :: HashMap String Shortcut
+    , stShortcuts    :: HashMap ShortcutLabel Shortcut
       -- | Per-channel settings
-    , stChannels     :: HashMap String ChanSettings
+    , stChannels     :: HashMap Channel ChanSettings
+      -- | Maps host names to host labels
+    , stDevHosts     :: HashMap DevHost DevHostLabel
+      -- | A generic key-value mapping intended to refer to URLs by short
+      -- labels. This is a global mapping, and there are also per-channel
+      -- mappings in 'ChanSettings'.
+    , stLocations    :: HashMap LocationLabel Location
+      -- | Users who can ask the bot to send an arbitrary message in an
+      -- arbitrary channel. This gives a lot of power but is also dangerous,
+      -- use with care. There are also per-channel puppeteers, see
+      -- 'ChanSettings'.
+    , stPuppeteers   :: HashSet Nickname
     }
 
 -- | Alias for the settings option type
@@ -158,11 +257,11 @@
 
 -- | A message left to an offline user, for them to read later.
 data Memo = Memo
-    { memoTime    :: String
-    , memoSender  :: String
-    , memoRecvIn  :: Maybe String
-    , memoSendIn  :: Maybe String
-    , memoContent :: String
+    { memoTime    :: Text
+    , memoSender  :: Nickname
+    , memoRecvIn  :: Maybe Channel
+    , memoSendIn  :: Maybe Channel
+    , memoContent :: MsgContent
     }
 
 -- | History display options per channel
@@ -176,7 +275,7 @@
 -- | Per-user options, consider private user info
 data UserOptions = UserOptions
     { -- | History display options per channel
-      uoHistoryDisplay :: HashMap String HistoryDisplay
+      uoHistoryDisplay :: HashMap Channel HistoryDisplay
     }
 
 -- | Read-write custom bot state
@@ -186,13 +285,18 @@
       -- | Settings tree and access definition for UI
     , bsSTree       :: SettingsTree
       -- | Memos waiting for users to connect.
-    , bsMemos       :: HashMap String [Memo]
+    , bsMemos       :: HashMap Nickname [Memo]
       -- | Per-user options
-    , bsUserOptions :: HashMap String UserOptions
+    , bsUserOptions :: HashMap Nickname UserOptions
       -- | Known nicks in channels
-    , bsKnownNicks  :: HashMap String (HashSet String)
+    , bsKnownNicks  :: HashMap Channel (HashSet Nickname)
       -- | Time of last message per channel.
-    , bsLastMsgTime :: HashMap String UTCTime
+    , bsLastMsgTime :: HashMap Channel UTCTime
+      -- | Channels for which puppet mode is enabled, and by which user.
+    , bsPuppet      :: HashMap Channel Nickname
+      -- | Whether private puppet mode is enabled, and by which user. It allows
+      -- the user to ask the bot to send a private message to another user.
+    , bsPrivPuppet  :: Maybe Nickname
     }
 
 -- | Shortcut alias for bot session monad
@@ -203,3 +307,14 @@
 
 -- | Shortcut alias for event handler function type
 type ExtEventHandler = EventHandler BotEnv BotState ExtEvent
+
+-- | The type of command response functions
+type Respond
+    =  Maybe Channel
+    -> Nickname
+    -> [Text]
+    -> (MsgContent -> BotSession ())
+    -> BotSession ()
+
+-- | Bot command type
+type BotCmd = Command BotEnv BotState
diff --git a/src/FunBot/UserOptions.hs b/src/FunBot/UserOptions.hs
--- a/src/FunBot/UserOptions.hs
+++ b/src/FunBot/UserOptions.hs
@@ -1,6 +1,6 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
  -
  - ♡ Copying is an act of love. Please copy, reuse and share.
  -
@@ -34,15 +34,18 @@
 import Control.Monad.IO.Class (liftIO)
 import Data.Aeson
 import Data.JsonState
-import Data.List (intercalate)
 import Data.Maybe (fromMaybe)
+import Data.Monoid ((<>))
+import Data.Text (Text, intercalate)
+import Formatting
 import FunBot.Config (stateSaveInterval, configuration, userOptsFilename)
+import FunBot.Settings.Instances ()
 import FunBot.Types
 import FunBot.Util (getHistoryLines)
 import Network.IRC.Fun.Bot.Chat (sendToUser)
 import Network.IRC.Fun.Bot.State
 import Network.IRC.Fun.Bot.Types (Config (cfgStateRepo))
-import Text.Printf (printf)
+import Network.IRC.Fun.Types.Base
 
 import qualified Data.HashMap.Lazy as M
 
@@ -66,9 +69,7 @@
 defUserOpts = UserOptions
     { uoHistoryDisplay = M.empty }
 
-getUserHistoryOpts :: String -- ^ User nickname
-                   -> String -- ^ Channel
-                   -> BotSession HistoryDisplay
+getUserHistoryOpts :: Nickname -> Channel -> BotSession HistoryDisplay
 getUserHistoryOpts nick chan = do
     opts <- getStateS bsUserOptions
     let user = M.lookup nick opts
@@ -80,33 +81,47 @@
 -- Command Implementation
 -------------------------------------------------------------------------------
 
-showEnabled :: Bool -> String
+showEnabled :: Bool -> Text
 showEnabled True  = "Enabled"
 showEnabled False = "Disabled"
 
-showMaxLines :: Int -> String
-showMaxLines n = show n ++ " lines"
+showMaxLines :: Int -> Text
+showMaxLines n = sformat (int % " lines") n
 
-formatHistoryOpts :: String -> String -> Maybe HistoryDisplay -> String
+formatHistoryOpts :: Nickname -> Channel -> Maybe HistoryDisplay -> MsgContent
 formatHistoryOpts nick chan mhd =
     let defSuffix = " [default]"
         (enabled, maxLines) =
             case mhd of
                 Nothing ->
-                    ( showEnabled defaultEnabled ++ defSuffix
-                    , showMaxLines defaultMaxLines ++ defSuffix
+                    ( showEnabled defaultEnabled <> defSuffix
+                    , showMaxLines defaultMaxLines <> defSuffix
                     )
                 Just hd ->
                     ( showEnabled $ hdEnabled hd
                     , showMaxLines $ hdMaxLines hd
                     )
-    in  printf "History display of %v for %v: %v, %v"
-            chan nick enabled maxLines
+    in  MsgContent $
+        sformat
+            ( "History display of "
+            % stext
+            % " for "
+            % stext
+            % ": "
+            % stext
+            % ", "
+            % stext
+            )
+            (unChannel chan)
+            (unNickname nick)
+            enabled
+            maxLines
 
-modifyOpts :: String                             -- ^ User nickname
-           -> String                             -- ^ Channel
-           -> (HistoryDisplay -> HistoryDisplay) -- ^ Modification
-           -> BotSession ()
+modifyOpts
+    :: Nickname
+    -> Channel
+    -> (HistoryDisplay -> HistoryDisplay)
+    -> BotSession ()
 modifyOpts nick chan f = do
     opts <- getStateS bsUserOptions
     let userPrev = M.lookupDefault defUserOpts nick opts
@@ -123,9 +138,7 @@
 -- Commands
 -------------------------------------------------------------------------------
 
-sendHistoryOpts :: String -- ^ User nickname
-                -> String -- ^ Channel
-                -> BotSession ()
+sendHistoryOpts :: Nickname -> Channel -> BotSession ()
 sendHistoryOpts nick chan = do
     opts <- getStateS bsUserOptions
     let user = M.lookup nick opts
@@ -133,50 +146,62 @@
         mhd = hdmap >>= M.lookup chan
     sendToUser nick $ formatHistoryOpts nick chan mhd
 
-sendChannels :: String -- ^ User nickname
-             -> BotSession ()
+sendChannels :: Nickname -> BotSession ()
 sendChannels nick = do
-    muser <- liftM (M.lookup nick) $ getStateS bsUserOptions
+    muser <- fmap (M.lookup nick) $ getStateS bsUserOptions
     let mhdmap = fmap uoHistoryDisplay muser
         mkeys = fmap M.keys mhdmap
         keys = fromMaybe [] mkeys
         l = if null keys
                 then "(none)"
-                else intercalate ", " keys
-    sendToUser nick $ "Options stored for channels: " ++ l
+                else intercalate ", " $ map unChannel keys
+    sendToUser nick $ MsgContent $ "Options stored for channels: " <> l
 
-setEnabled :: String -- ^ User nickname
-           -> String -- ^ Channel
-           -> Bool   -- ^ Whether to enable (or disable)
-           -> BotSession ()
+setEnabled :: Nickname -> Channel -> Bool -> BotSession ()
 setEnabled nick chan enabled = do
     modifyOpts nick chan $ \ hd -> hd { hdEnabled = enabled }
-    sendToUser nick $
-        printf "History display of %v: %v" chan (showEnabled enabled)
+    sendToUser nick $ MsgContent $
+        sformat
+            ( "History display of "
+            % stext
+            % ": "
+            % stext
+            )
+            (unChannel chan)
+            (showEnabled enabled)
 
-setMaxLines :: String -- ^ User nickname
-            -> String -- ^ Channel
-            -> Int    -- ^ Number of lines
-            -> BotSession ()
+setMaxLines :: Nickname -> Channel -> Int -> BotSession ()
 setMaxLines nick chan maxLines = do
     modifyOpts nick chan $ \ hd -> hd { hdMaxLines = maxLines }
     hls <- getHistoryLines chan
-    sendToUser nick $
-        printf
-            "History display length for %v: %v\n\
-            \(I keep in my logs up to %v for %v)"
-            chan (showMaxLines maxLines) (showMaxLines hls) chan
+    sendToUser nick $ MsgContent $
+        sformat
+            ( "History display length for "
+            % stext
+            % ": "
+            % stext
+            % "\n(I keep in my logs up to "
+            % stext
+            % " for "
+            % stext
+            % ")"
+            )
+            (unChannel chan)
+            (showMaxLines maxLines)
+            (showMaxLines hls)
+            (unChannel chan)
 
-eraseOpts :: String -- ^ User nickname
-          -> BotSession ()
+eraseOpts :: Nickname -> BotSession ()
 eraseOpts nick = do
     opts <- getStateS bsUserOptions
     case M.lookup nick opts of
-        Nothing   -> sendToUser nick "You don't have stored options."
+        Nothing   ->
+            sendToUser nick $ MsgContent "You don’t have stored options."
         Just user -> do
             modifyState $ \ s -> s { bsUserOptions = M.delete nick opts }
             saveUserOptions
-            sendToUser nick "All your options have been reset to defaults."
+            sendToUser nick $
+                MsgContent "All your options have been reset to defaults."
 
 -------------------------------------------------------------------------------
 -- Persistence
@@ -206,7 +231,7 @@
         [ "history-display" .= hd
         ]
 
-loadUserOptions :: IO (M.HashMap String UserOptions)
+loadUserOptions :: IO (M.HashMap Nickname UserOptions)
 loadUserOptions = do
     r <- loadState $
          stateFilePath userOptsFilename (cfgStateRepo configuration)
@@ -215,7 +240,7 @@
         Left (True, e)  -> error $ "Failed to parse user options file: " ++ e
         Right s         -> return s
 
-mkSaveUserOptions :: IO (M.HashMap String UserOptions -> IO ())
+mkSaveUserOptions :: IO (M.HashMap Nickname UserOptions -> IO ())
 mkSaveUserOptions =
     mkSaveStateChoose
         stateSaveInterval
diff --git a/src/FunBot/Util.hs b/src/FunBot/Util.hs
--- a/src/FunBot/Util.hs
+++ b/src/FunBot/Util.hs
@@ -13,24 +13,40 @@
  - <http://creativecommons.org/publicdomain/zero/1.0/>.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module FunBot.Util
     ( (!?)
-    , replaceMaybe
+      --, replaceMaybe
     , passes
     , passesBy
     , getTimeStr
     , getHistoryLines
+    , cmds
+    , helps
+    , looksLikeChan
+    , notchan
+    , looksLikeNick
+    , notnick
+    , unsnoc
     )
 where
 
 import Control.Monad (liftM)
 import Control.Monad.IO.Class (liftIO)
+import Data.Char
 import Data.Maybe (listToMaybe)
+import Data.Monoid ((<>))
+import Data.Text (Text)
 import FunBot.Types
 import Network.IRC.Fun.Bot.State (askTimeGetter, getChanInfo)
-import Network.IRC.Fun.Bot.Types (ChanInfo (ciHistoryLines))
+import Network.IRC.Fun.Bot.Types (ChanInfo (ciHistoryLines), CommandName (..))
+import Network.IRC.Fun.Color.Style
+import Network.IRC.Fun.Types.Base
 
+import qualified Data.CaseInsensitive as CI (mk)
 import qualified Data.HashMap.Lazy as M (lookup)
+import qualified Data.Text as T
 
 -- | List index operator, starting from 0. Like @!!@ but returns a 'Maybe'
 -- instead of throwing an exception. On success, returns 'Just' the item. On
@@ -41,12 +57,16 @@
 -- | Replace the list item at the given position, with the given new item.
 -- Return the resulting list. If the position is out of range, return
 -- 'Nothing'.
-replaceMaybe :: [a] -> Int -> a -> Maybe [a]
-replaceMaybe l i y =
-    case splitAt i l of
-        (_, [])   -> Nothing
-        (b, x:xs) -> Just $ b ++ y : xs
+--replaceMaybe :: [a] -> Int -> a -> Maybe [a]
+--replaceMaybe l i y = setMaybe l i (const y)
 
+-- | Like 'replaceMaybe', but takes a function and applies to the item.
+--setMaybe :: [a] -> Int -> (a -> a) -> Maybe [a]
+--setMaybe l i f =
+--    case splitAt i l of
+--        (_, [])   -> Nothing
+--        (b, x:xs) -> Just $ b ++ f x : xs
+
 -- | Check whether a value passes a given filter.
 passes :: Eq a => a -> Filter a -> Bool
 v `passes` (Accept l) = v `elem` l
@@ -58,14 +78,60 @@
 passesBy p v (Reject l) = all (not . p v) l
 
 -- | Get a string specifying the current UTC time using the time getter.
-getTimeStr :: BotSession String
+getTimeStr :: BotSession Text
 getTimeStr = do
     getTime <- askTimeGetter
-    liftIO $ liftM snd getTime
+    liftIO $ fmap snd getTime
 
 -- | Get the number of history lines recorded in memory for a given channel. If
 -- the channel doesn't have state held for it, 0 is returned.
-getHistoryLines :: String -> BotSession Int
+getHistoryLines :: Channel -> BotSession Int
 getHistoryLines chan = do
     cs <- getChanInfo
     return $ maybe 0 ciHistoryLines $ M.lookup chan cs
+
+-- | Helper for specifying command names
+cmds :: [Text] -> [CommandName]
+cmds = map $ CommandName . CI.mk
+
+-- | Helper for specifying command help
+helps :: [(Text, Text)] -> Text
+helps l = T.intercalate "\n" $ map (uncurry f) l
+    where
+    maxlen = maximum $ map (T.length . fst) l
+    nspaces spec = maxlen - T.length spec
+    spaces spec = T.replicate (nspaces spec) " "
+    f spec desc = T.concat
+        [ "‘"
+        , encode $ Bold #> plain spec
+        , "’"
+        , spaces spec
+        , " - "
+        , desc
+        ]
+
+looksLikeChan (Channel chan) =
+    case T.uncons chan of
+        Nothing     -> False
+        Just (c, _) -> c `elem` ("#+!&" :: String)
+
+notchan chan = MsgContent $ unChannel chan <> " doesn’t look like a channel name."
+
+looksLikeNick nick =
+    case T.uncons nick of
+        Nothing     -> False
+        Just (c, r) -> first c && T.all rest r
+    where
+    isAsciiLetter c = isAsciiLower c || isAsciiUpper c
+    isSpecial = (`elem` ("[]\\`_^{|}" :: String))
+    first c = isAsciiLetter c || isSpecial c
+    rest c = isAsciiLetter c || isDigit c || isSpecial c || c == '-'
+
+notnick nick = MsgContent $ nick <> " doesn’t look like a nickname."
+
+-- | Trivial @unsnoc@ for Text.
+unsnoc :: Text -> Maybe (Text, Char)
+unsnoc t =
+    if T.null t
+        then Nothing
+        else Just (T.init t, T.last t)
diff --git a/src/Main.hs b/src/Main.hs
--- a/src/Main.hs
+++ b/src/Main.hs
@@ -1,6 +1,6 @@
 {- This file is part of funbot.
  -
- - Written in 2015 by fr33domlover <fr33domlover@rel4tion.org>.
+ - Written in 2015, 2016 by fr33domlover <fr33domlover@riseup.net>.
  -
  - ♡ Copying is an act of love. Please copy, reuse and share.
  -
@@ -13,21 +13,26 @@
  - <http://creativecommons.org/publicdomain/zero/1.0/>.
  -}
 
+{-# LANGUAGE OverloadedStrings #-}
+
 module Main (main) where
 
 import Control.Concurrent.Chan (newChan)
 import Control.Monad.IO.Class (liftIO)
+import Data.Default.Class (def)
 import Data.Settings.Section (empty)
-import FunBot.Commands (commandSet)
+import FunBot.Commands
 import FunBot.ExtHandlers (handler)
 import FunBot.KnownNicks
 import FunBot.Memos
 import FunBot.Settings
+import FunBot.Settings.Persist
+import FunBot.Settings.Sections
 import FunBot.Sources
 import FunBot.Types
 import FunBot.UserOptions
+import FunBot.Util (cmds)
 import Network.IRC.Fun.Bot (runBot)
-import Network.IRC.Fun.Bot.Behavior (defaultBehavior)
 import Network.IRC.Fun.Bot.EventMatch
 import Network.IRC.Fun.Bot.Types (Behavior (..), EventMatchSpace (..))
 import Web.Feed.Collect (newCommandQueue)
@@ -56,6 +61,8 @@
     , bsUserOptions = userOpts
     , bsKnownNicks  = nicks
     , bsLastMsgTime = M.empty
+    , bsPuppet      = M.empty
+    , bsPrivPuppet  = Nothing
     }
 
 -- | Event detector specification
@@ -81,14 +88,16 @@
     , defaultMatch
     ]
     where
-    privCmds =
+    privCmds = cmds
         [ "help", "info", "echo", "tell", "get", "show-opts", "enable-history"
         , "disable-history", "set-history-lines", "erase-opts", "show-history"
+        , "where", "lwhere", "gwhere"
         ]
 
 
 -- | Bot behavior definition
-behavior = defaultBehavior
+behavior :: Behavior BotEnv BotState
+behavior = def
     { handleJoin       = H.handleJoin
     , handleMsg        = H.handleMsg
     , handleAction     = H.handleAction
@@ -100,11 +109,12 @@
 
 -- | Additional events sources
 mkSources state =
-    [ webListenerSource
+    [ webListenerSource C.webErrorLogFile
     , feedWatcherSource C.feedErrorLogFile state
     , loopbackSource
     ]
 
+main :: IO ()
 main = do
     liftIO $ putStrLn "Loading bot settings"
     sets <- loadBotSettings
diff --git a/state-default/settings.json b/state-default/settings.json
--- a/state-default/settings.json
+++ b/state-default/settings.json
@@ -1,5 +1,9 @@
 {
     "repos"     : {},
     "feeds"     : {},
-    "shortcuts" : {}
+    "shortcuts" : {},
+    "channels"  : {},
+    "dev-hosts" : {},
+    "locations" : {},
+    "puppeteers" : {}
 }
