Peers & cache

How peers work

Every chat in Telegram is a peer — user, group, or channel — addressed by a numeric id plus an access hash. The hash is what makes peer references not enumerable.

What a peer id is

A Telegram peer is identified by a numeric id. Users get small positive ids (123456), basic groups get negative ids (-123456), channels and supergroups get a marked-channel id (-100123456) that the official clients display unmarked. Everywhere gogram says "peer id," either form is fine; the library normalises.

The id alone is not enough to call most methods. The server also wants an access hash: a 64-bit secret that proves you obtained the id legitimately (you saw the user in a chat, you searched for the username, the bot was added to the group). Without it you get PEER_ID_INVALID.

InputPeer

The pair (id + access hash + kind) is an InputPeer. Three concrete kinds:

  • InputPeerUser — user or bot.
  • InputPeerChat — legacy basic group (small chats, no admin tools).
  • InputPeerChannel — supergroup or channel.

Any high-level method that takes peerID any ends up converting it to an InputPeer under the hood. The conversion is what the peer cache exists to accelerate.

peer, err := client.ResolvePeer("@durov")
peer, err := client.ResolvePeer("@durov")

ID ranges

RangeKind
1 — 999_999_999_999user or bot
-1 — -999_999_999basic group
-1000000000000 — -1999999999999channel or supergroup (the -100 prefix is the official display form)
777000Telegram service notifications

The -100 prefix and how gogram handles it

The -100-prefixed form is the Bot API convention for channels and supergroups. Internally in MTProto, the same channel is just a bare positive id like 1234567890, with the kind carried separately by the peer type. The negative prefix is a Bot API artefact so it can pack channel and user ids into a single signed space without ambiguity.

gogram lets you pass either form to any method that takes peerID any and does the conversion for you. That means all of these work interchangeably:

// all three of these resolve to the same channel:
client.SendMessage(-1001234567890, "hi")   // full -100 prefix
client.SendMessage(1234567890,     "hi")   // bare positive id
client.SendMessage("-1001234567890", "hi") // string form
client.SendMessage("@my_channel",  "hi")   // username

// bots exposed only the raw id?  either form is fine:
channelID := int64(1234567890)
client.SendMessage(channelID, "hi")
// all three of these resolve to the same channel:
client.SendMessage(-1001234567890, "hi")   // full -100 prefix
client.SendMessage(1234567890,     "hi")   // bare positive id
client.SendMessage("-1001234567890", "hi") // string form
client.SendMessage("@my_channel",  "hi")   // username

// bots exposed only the raw id?  either form is fine:
channelID := int64(1234567890)
client.SendMessage(channelID, "hi")

Under the hood the library normalises the id when resolving, and caches the resulting InputPeerChannel so subsequent calls skip the resolution round-trip:

// gogram normalises internally: -100 is treated as a display
// convention for supergroups and channels, not a distinct id
// space. When resolving a peer id, the library:
//
//   1. Checks the cache under both the -100 and the bare id
//   2. If found, uses the cached (id, access_hash) pair
//   3. Falls back to contacts.resolveUsername or the peer
//      map from the last update batch
//   4. Panics? No. Returns PEER_ID_INVALID if the (id, hash)
//      pair is genuinely unknown to your session.

peer, err := client.ResolvePeer(int64(-1001234567890))
peer, err  = client.ResolvePeer(int64(1234567890))
// both return the same InputPeerChannel from cache after
// the first hit.
// gogram normalises internally: -100 is treated as a display
// convention for supergroups and channels, not a distinct id
// space. When resolving a peer id, the library:
//
//   1. Checks the cache under both the -100 and the bare id
//   2. If found, uses the cached (id, access_hash) pair
//   3. Falls back to contacts.resolveUsername or the peer
//      map from the last update batch
//   4. Panics? No. Returns PEER_ID_INVALID if the (id, hash)
//      pair is genuinely unknown to your session.

peer, err := client.ResolvePeer(int64(-1001234567890))
peer, err  = client.ResolvePeer(int64(1234567890))
// both return the same InputPeerChannel from cache after
// the first hit.

Type-converting Peer results

MTProto is a tagged-union protocol. Many fields on incoming messages are typed as an abstract interface (MessageMedia, Peer, User, Chat), and the concrete kind determines which fields are readable. The Go pattern is a type switch or a type assertion.

MessageMedia

The most common one. m.Media() returns a MessageMediainterface; every media kind is a distinct concrete type:

client.On("message:*", func(m *telegram.NewMessage) error {
	switch v := m.Media().(type) {
	case *telegram.MessageMediaPhoto:
		photo, ok := v.Photo.(*telegram.PhotoObj)
		if !ok { return nil }
		fmt.Println("photo id", photo.ID, "sizes", len(photo.Sizes))

	case *telegram.MessageMediaDocument:
		doc, ok := v.Document.(*telegram.DocumentObj)
		if !ok { return nil }
		fmt.Println("document", doc.MimeType, "size", doc.Size)

	case *telegram.MessageMediaGeo:
		pt, ok := v.Geo.(*telegram.GeoPointObj)
		if !ok { return nil }
		fmt.Printf("geo %.5f, %.5f\n", pt.Lat, pt.Long)

	case *telegram.MessageMediaPoll:
		fmt.Println("poll:", v.Poll.Question)

	case *telegram.MessageMediaContact:
		fmt.Println("contact", v.PhoneNumber, v.FirstName)

	case nil:
		// text-only message
	default:
		fmt.Printf("other media kind: %T\n", v)
	}
	return nil
})
client.On("message:*", func(m *telegram.NewMessage) error {
	switch v := m.Media().(type) {
	case *telegram.MessageMediaPhoto:
		photo, ok := v.Photo.(*telegram.PhotoObj)
		if !ok { return nil }
		fmt.Println("photo id", photo.ID, "sizes", len(photo.Sizes))

	case *telegram.MessageMediaDocument:
		doc, ok := v.Document.(*telegram.DocumentObj)
		if !ok { return nil }
		fmt.Println("document", doc.MimeType, "size", doc.Size)

	case *telegram.MessageMediaGeo:
		pt, ok := v.Geo.(*telegram.GeoPointObj)
		if !ok { return nil }
		fmt.Printf("geo %.5f, %.5f\n", pt.Lat, pt.Long)

	case *telegram.MessageMediaPoll:
		fmt.Println("poll:", v.Poll.Question)

	case *telegram.MessageMediaContact:
		fmt.Println("contact", v.PhoneNumber, v.FirstName)

	case nil:
		// text-only message
	default:
		fmt.Printf("other media kind: %T\n", v)
	}
	return nil
})

Notice the double type-assert on Photo and Document— the media wrapper is one union, the inner photo/document is another. Telegram sometimes sends PhotoEmpty or DocumentEmpty placeholders (photo deleted, access hash rotated), so always check ok.

Peer

Peer on a raw update or dialog tells you the kind of chat by concrete type:

switch p := m.Peer.(type) {
case *telegram.PeerUser:
	fmt.Println("DM with user", p.UserID)
case *telegram.PeerChat:
	fmt.Println("basic group", p.ChatID)
case *telegram.PeerChannel:
	fmt.Println("channel/supergroup", p.ChannelID)
}
switch p := m.Peer.(type) {
case *telegram.PeerUser:
	fmt.Println("DM with user", p.UserID)
case *telegram.PeerChat:
	fmt.Println("basic group", p.ChatID)
case *telegram.PeerChannel:
	fmt.Println("channel/supergroup", p.ChannelID)
}

User and Chat

User is an interface with two implementations: UserObj for real accounts and UserEmpty for deleted or never-existed users. Type-assert to reach the real fields:

for _, u := range chat.Users {
	user, ok := u.(*telegram.UserObj)
	if !ok {
		// UserEmpty — server sent a deleted-account placeholder
		continue
	}
	fmt.Println(user.ID, user.Username, user.Bot)
}
for _, u := range chat.Users {
	user, ok := u.(*telegram.UserObj)
	if !ok {
		// UserEmpty — server sent a deleted-account placeholder
		continue
	}
	fmt.Println(user.ID, user.Username, user.Bot)
}

Chatfollows the same shape — assert to *telegram.ChatObj for basic groups or *telegram.Channel for channels and supergroups.

InputPeer from ResolvePeer

peer, err := client.ResolvePeer("@durov")
if err != nil { return err }

// ResolvePeer returns the InputPeer interface. To reach into
// a specific concrete kind, type-assert:
switch v := peer.(type) {
case *telegram.InputPeerUser:
	fmt.Println("user", v.UserID, "hash", v.AccessHash)
case *telegram.InputPeerChannel:
	fmt.Println("channel", v.ChannelID, "hash", v.AccessHash)
case *telegram.InputPeerChat:
	fmt.Println("basic group", v.ChatID)
}
peer, err := client.ResolvePeer("@durov")
if err != nil { return err }

// ResolvePeer returns the InputPeer interface. To reach into
// a specific concrete kind, type-assert:
switch v := peer.(type) {
case *telegram.InputPeerUser:
	fmt.Println("user", v.UserID, "hash", v.AccessHash)
case *telegram.InputPeerChannel:
	fmt.Println("channel", v.ChannelID, "hash", v.AccessHash)
case *telegram.InputPeerChat:
	fmt.Println("basic group", v.ChatID)
}

Min peers

When you see a peer mentioned in an update without ever having interacted with it — for example a user who replied to a message in a public channel you scrolled through — the server marks the peer object as min. It is enough to render the username/photo but not enough to call methods.