Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ and unread state as the logged-in user.

| Tool | Description |
| --- | --- |
| `list_unread_channels` | List channels and supergroups that currently have unread messages, with unread counts. |
| `list_unread_channels` | List broadcast channels that currently have unread messages, with unread counts. |
| `read_channel_unread` | Read the unread messages of a channel (by `@username` or numeric ID), newest first. Reading does **not** mark them as read. |
| `mark_channel_read` | Mark all messages in a specific channel or supergroup as read. |
| `mark_all_channels_read` | Mark every unread channel and supergroup as read in one call. |
| `mark_channel_read` | Mark all messages in a specific broadcast channel as read. |
| `mark_all_channels_read` | Mark every unread broadcast channel as read in one call. |
| `list_chats` | List cached dialogs (private, groups, supergroups, channels) with id/title/username/type/unread. |
| `get_chat_messages` | Fetch recent history from any chat (by id, @user, me, t.me link). |
| `search_chat_messages` | Search messages in a chat (optional filter: photo/video/document/url/...). |
| `send_message` | Send text; optional reply_to_message_id, silent, no_webpage. |
| `send_file` | Send file from TG_FILE_ROOT; optional caption, as_photo, reply, silent. |

## How it works

Expand Down Expand Up @@ -67,6 +72,7 @@ tool call. Instead, mirroring [tdlib](https://github.com/tdlib/td)'s strategy:
| `TG_SESSION_DIR` | no | `session` | Directory for the session and state database. |
| `MCP_ADDR` | no | `127.0.0.1:8080` | Address for the MCP HTTP server. |
| `LOG_LEVEL` | no | `info` | `debug`, `info`, `warn`, or `error`. |
| `TG_FILE_ROOT` | no | — | Base dir for send_file. Empty disables send_file with clear error. Paths are resolved inside this root; traversal rejected. |

## Running

Expand Down Expand Up @@ -101,6 +107,9 @@ Point your MCP client at the HTTP endpoint (adjust the address to `MCP_ADDR`):
invocation.
- Unread detection compares each message ID against the dialog's
`read_inbox_max_id`; messages newer than that boundary are returned.
- `list_chats`, `get_chat_messages`, and `search_chat_messages` include
`limited: true` when the returned results were capped. Message tools also
return `next_offset_id` for continuing with another call.
- After a long disconnect, a too-long difference is resynced automatically: a
single channel via `messages.getPeerDialogs`, or the whole list via a full
re-bootstrap. Deleting `<session>/updates.bolt` forces a clean re-bootstrap on
Expand Down
111 changes: 84 additions & 27 deletions cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"sync"

"go.uber.org/zap"

"github.com/gotd/td/tg"
)

// dialogCache is an in-memory snapshot of the user's channel and supergroup
Expand All @@ -20,20 +22,24 @@ import (
// the updates manager then reconciles it via getDifference.
type dialogCache struct {
mu sync.RWMutex
channels map[int64]UnreadChannel
channels map[string]UnreadChannel

store *dialogStore // optional; nil disables persistence.
lg *zap.Logger
}

func newDialogCache(store *dialogStore, lg *zap.Logger) *dialogCache {
return &dialogCache{
channels: make(map[int64]UnreadChannel),
channels: make(map[string]UnreadChannel),
store: store,
lg: lg,
}
}

func dialogCacheKey(ch UnreadChannel) string {
return dialogKey(ch)
}

// loadFromStore replaces the in-memory cache with the persisted dialogs and
// returns how many were loaded. Returns 0 when no store is configured or none
// are persisted yet.
Expand All @@ -47,14 +53,9 @@ func (c *dialogCache) loadFromStore() (int, error) {
return 0, err
}

m := make(map[int64]UnreadChannel, len(chs))
m := make(map[string]UnreadChannel, len(chs))
for _, ch := range chs {
// Drop group chats (supergroups) persisted before tgmcp narrowed to
// broadcast channels only.
if !ch.Broadcast {
continue
}
m[ch.ID] = ch
m[dialogCacheKey(ch)] = ch
}

c.mu.Lock()
Expand All @@ -67,9 +68,9 @@ func (c *dialogCache) loadFromStore() (int, error) {
// replaceAll swaps the entire cache content and persists it. Used by the
// one-time full fetch.
func (c *dialogCache) replaceAll(chs []UnreadChannel) {
m := make(map[int64]UnreadChannel, len(chs))
m := make(map[string]UnreadChannel, len(chs))
for _, ch := range chs {
m[ch.ID] = ch
m[dialogCacheKey(ch)] = ch
}

c.mu.Lock()
Expand All @@ -83,14 +84,18 @@ func (c *dialogCache) replaceAll(chs []UnreadChannel) {
}
}

// unread returns the cached channels that currently have unread messages or are
// manually marked as unread.
// unread returns the cached broadcast channels that currently have unread
// messages or are manually marked as unread. Non-broadcast dialogs are
// excluded to preserve legacy behavior for list/mark tools.
func (c *dialogCache) unread() []UnreadChannel {
c.mu.RLock()
defer c.mu.RUnlock()

var out []UnreadChannel
for _, ch := range c.channels {
if !ch.Broadcast {
continue
}
if ch.UnreadCount > 0 || ch.UnreadMark {
out = append(out, ch)
}
Expand All @@ -99,19 +104,32 @@ func (c *dialogCache) unread() []UnreadChannel {
return out
}

// all returns every cached dialog regardless of type or unread state.
func (c *dialogCache) all() []UnreadChannel {
c.mu.RLock()
defer c.mu.RUnlock()

var out []UnreadChannel
for _, ch := range c.channels {
out = append(out, ch)
}

return out
}

// find resolves a cached channel by numeric ID or @username.
func (c *dialogCache) find(target string) (UnreadChannel, bool) {
target = strings.TrimPrefix(strings.TrimSpace(target), "@")
wantID, isID := parseID(target)
if isID {
return c.get(wantID)
}

c.mu.RLock()
defer c.mu.RUnlock()

for _, ch := range c.channels {
if isID && ch.ID == wantID {
return ch, true
}
if !isID && strings.EqualFold(ch.Username, target) {
if strings.EqualFold(ch.Username, target) {
return ch, true
}
}
Expand All @@ -124,16 +142,42 @@ func (c *dialogCache) get(id int64) (UnreadChannel, bool) {
c.mu.RLock()
defer c.mu.RUnlock()

ch, ok := c.channels[id]
for _, kind := range []string{"channel", "chat", "user"} {
ch, ok := c.channels[dialogKeyParts(kind, id)]
if ok {
return ch, true
}
}

return UnreadChannel{}, false
}

// getPeer returns a cached dialog matching the exact input peer namespace.
func (c *dialogCache) getPeer(p any) (UnreadChannel, bool) {
var key string
switch v := p.(type) {
case *tg.InputPeerChannel:
key = dialogKeyParts("channel", v.ChannelID)
case *tg.InputPeerChat:
key = dialogKeyParts("chat", v.ChatID)
case *tg.InputPeerUser:
key = dialogKeyParts("user", v.UserID)
default:
return UnreadChannel{}, false
}

c.mu.RLock()
defer c.mu.RUnlock()

ch, ok := c.channels[key]
return ch, ok
}

// set upserts a fully-resolved channel and persists it. Used to resync a single
// channel after a too-long difference.
func (c *dialogCache) set(ch UnreadChannel) {
c.mu.Lock()
c.channels[ch.ID] = ch
c.channels[dialogCacheKey(ch)] = ch
c.mu.Unlock()

c.persist(ch)
Expand All @@ -144,11 +188,22 @@ func (c *dialogCache) set(ch UnreadChannel) {
// private).
func (c *dialogCache) remove(channelID int64) {
c.mu.Lock()
_, ok := c.channels[channelID]
delete(c.channels, channelID)
var key string
for _, kind := range []string{"channel", "chat", "user"} {
k := dialogKeyParts(kind, channelID)
if _, ok := c.channels[k]; ok {
key = k
break
}
}
if key == "" {
c.mu.Unlock()
return
}
delete(c.channels, key)
c.mu.Unlock()

if !ok || c.store == nil {
if c.store == nil {
return
}

Expand All @@ -164,14 +219,15 @@ func (c *dialogCache) remove(channelID int64) {
// be resolved, in which case the message is dropped.
func (c *dialogCache) observeIncoming(channelID int64, build func() (UnreadChannel, bool)) {
c.mu.Lock()
ch, ok := c.channels[channelID]
key := dialogKeyParts("channel", channelID)
ch, ok := c.channels[key]
if ok {
ch.UnreadCount++
c.channels[channelID] = ch
c.channels[key] = ch
} else if nch, built := build(); built {
nch.UnreadCount = 1
ch, ok = nch, true
c.channels[channelID] = nch
c.channels[dialogCacheKey(nch)] = nch
}
c.mu.Unlock()

Expand Down Expand Up @@ -212,10 +268,11 @@ func (c *dialogCache) markRead(channelID int64) {
// Unknown channels are ignored.
func (c *dialogCache) update(channelID int64, mutate func(*UnreadChannel)) {
c.mu.Lock()
ch, ok := c.channels[channelID]
key := dialogKeyParts("channel", channelID)
ch, ok := c.channels[key]
if ok {
mutate(&ch)
c.channels[channelID] = ch
c.channels[key] = ch
}
c.mu.Unlock()

Expand Down
4 changes: 4 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Config struct {
SessionDir string
HTTPAddr string
LogLevel string
FileRoot string
}

// LoadConfig reads configuration from the environment, optionally sourcing a
Expand All @@ -32,6 +33,7 @@ type Config struct {
// TG_SESSION_DIR - directory to store the session (default: "./session")
// MCP_ADDR - address for the MCP HTTP server to listen on (default: "127.0.0.1:8080")
// LOG_LEVEL - log level: debug, info, warn, error (default: "info")
// TG_FILE_ROOT - directory from which send_file may read files (default: disabled)
func LoadConfig() (Config, error) {
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
return Config{}, errors.Wrap(err, "load .env")
Expand Down Expand Up @@ -68,6 +70,8 @@ func LoadConfig() (Config, error) {
cfg.LogLevel = "info"
}

cfg.FileRoot = os.Getenv("TG_FILE_ROOT")

return cfg, nil
}

Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ func runServe(ctx context.Context, cfg Config) error {
// updates manager starts, so it is visible by the time updates flow.
api = client.API()

srv := &server{api: client.API(), cache: cache, msgs: msgs, lg: lg}
srv := &server{api: client.API(), cache: cache, msgs: msgs, lg: lg, fileRootVal: cfg.FileRoot}
m := mcp.NewServer(&mcp.Implementation{
Name: "tgmcp",
Version: "0.1.0",
Expand Down
Loading