Repository: aiogram/aiogram
Pull request #585 (2021-05-11 22:18:25)

Added get_url() method for Message object and shifted_id property for Chat object
# Description
Telegram has an ability to generate an URL for a message in group chat or channel.
This PR adds such feature to 3.x branch.
A new method `get_url()` is added with optional `force_private` flag (default to False).
If a group doesn't have a username or `force_private` flag is set, returns URL like `https://t.me/c/shifted_chat_id/message_id`
Otherwise, returns URL like `https://t.me/username/message_id`

This PR also adds `shifted_id` property for Chat object, which is used is private links in `get_url()` method.

## Type of change

Please delete options that are not relevant.

- [ ] Documentation (typos, code examples or any documentation update)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [x] This change requires a documentation update

# How Has This Been Tested?

With pytest, see "Files changed" section for more info

**Test Configuration**:
* Operating System: Manjaro KDE
* Python version: 3.9

# Checklist:

- [ ] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes


codecov[bot]: # [Codecov](https://codecov.io/gh/aiogram/aiogram/pull/585?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) Report
> Merging [#585](https://codecov.io/gh/aiogram/aiogram/pull/585?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (bc03032) into [dev-3.x](https://codecov.io/gh/aiogram/aiogram/commit/81427d0a8dc3219ca77474c79ccddf2f7bc2895d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) (81427d0) will **decrease** coverage by `0.17%`.
> The diff coverage is `25.00%`.

[![Impacted file tree graph](https://codecov.io/gh/aiogram/aiogram/pull/585/graphs/tree.svg?width=650&height=150&src=pr&token=cFqEr3p9MX&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)](https://codecov.io/gh/aiogram/aiogram/pull/585?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram)

```diff
@@             Coverage Diff             @@
##           dev-3.x     #585      +/-   ##
===========================================
- Coverage    98.29%   98.11%   -0.18%     
===========================================
  Files          238      238              
  Lines         4979     4990      +11     
===========================================
+ Hits          4894     4896       +2     
- Misses          85       94       +9     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `98.11% <25.00%> (-0.18%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/aiogram/aiogram/pull/585?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram) | Coverage Δ | |
|---|---|---|
| [aiogram/types/message.py](https://codecov.io/gh/aiogram/aiogram/pull/585/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS90eXBlcy9tZXNzYWdlLnB5) | `97.45% <11.11%> (-2.55%)` | :arrow_down: |
| [aiogram/types/chat.py](https://codecov.io/gh/aiogram/aiogram/pull/585/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aiogram#diff-YWlvZ3JhbS90eXBlcy9jaGF0LnB5) | `96.00% <66.66%> (-4.00%)` | :arrow_down: |


Patch:
diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py
index 7d735ac3..4c0db8c9 100644
--- a/aiogram/types/chat.py
+++ b/aiogram/types/chat.py
@@ -54,3 +54,19 @@ class Chat(TelegramObject):
     """*Optional*. Unique identifier for the linked chat, i.e. the discussion group identifier for a channel and vice versa; for supergroups and channel chats. This identifier may be greater than 32 bits and some programming languages may have difficulty/silent defects in interpreting it. But it is smaller than 52 bits, so a signed 64 bit integer or double-precision float type are safe for storing this identifier. Returned only in :class:`aiogram.methods.get_chat.GetChat`."""
     location: Optional[ChatLocation] = None
     """*Optional*. For supergroups, the location to which the supergroup is connected. Returned only in :class:`aiogram.methods.get_chat.GetChat`."""
+
+    @property
+    def shifted_id(self) -> int:
+        """
+        Returns shifted chat ID (positive and without "-100" prefix).
+        Mostly used for private links like t.me/c/chat_id/message_id
+
+        Currently supergroup/channel IDs have 10-digit ID after "-100" prefix removed.
+        However, these IDs might become 11-digit in future. So, first we remove "-100"
+        prefix and count remaining number length. Then we multiple
+        -1 * 10 ^ (number_length + 2)
+        Finally, self.id is substracted from that number
+        """
+        short_id = str(self.id).replace("-100", "")
+        shift = int(-1 * pow(10, len(short_id) + 2))
+        return shift - self.id
diff --git a/aiogram/types/message.py b/aiogram/types/message.py
index 67b3a594..8d72d0e5 100644
--- a/aiogram/types/message.py
+++ b/aiogram/types/message.py
@@ -1760,6 +1760,25 @@ class Message(TelegramObject):
 
         return DeleteMessage(chat_id=self.chat.id, message_id=self.message_id)
 
+    def get_url(self, force_private: bool = False) -> Optional[str]:
+        """
+        Returns message URL. Cannot be used in private (one-to-one) chats.
+        If chat has a username, returns URL like https://t.me/username/message_id
+        Otherwise (or if {force_private} flag is set), returns https://t.me/c/shifted_chat_id/message_id
+
+        :param force_private: if set, a private URL is returned even for a public chat
+        :return: string with full message URL
+        """
+        if self.chat.type in ("private", "group"):
+            return None
+
+        if not self.chat.username or force_private:
+            chat_value = f"c/{self.chat.shifted_id}"
+        else:
+            chat_value = self.chat.username
+
+        return f"https://t.me/{chat_value}/{self.message_id}"
+
 
 class ContentType(helper.Helper):
     mode = helper.HelperMode.snake_case


Test patch:
diff --git a/tests/test_api/test_methods/test_get_url.py b/tests/test_api/test_methods/test_get_url.py
new file mode 100644
index 00000000..3c769ca2
--- /dev/null
+++ b/tests/test_api/test_methods/test_get_url.py
@@ -0,0 +1,51 @@
+import datetime
+from typing import Optional
+
+import pytest
+from aiogram.types import Chat, Message
+
+from tests.mocked_bot import MockedBot
+
+
+class TestGetMessageUrl:
+    @pytest.mark.parametrize(
+        "chat_type,chat_id,chat_username,force_private,expected_result",
+        [
+            ["private", 123456, "username", False, None],
+            ["group", -123456, "username", False, None],
+            ["supergroup", -1001234567890, None, False, "https://t.me/c/1234567890/10"],
+            ["supergroup", -1001234567890, None, True, "https://t.me/c/1234567890/10"],
+            ["supergroup", -1001234567890, "username", False, "https://t.me/username/10"],
+            ["supergroup", -1001234567890, "username", True, "https://t.me/c/1234567890/10"],
+            ["channel", -1001234567890, None, False, "https://t.me/c/1234567890/10"],
+            ["channel", -1001234567890, None, True, "https://t.me/c/1234567890/10"],
+            ["channel", -1001234567890, "username", False, "https://t.me/username/10"],
+            ["channel", -1001234567890, "username", True, "https://t.me/c/1234567890/10"],
+            # 2 extra cases: 9-digit ID and 11-digit ID (without "-100")
+            ["supergroup", -100123456789, None, True, "https://t.me/c/123456789/10"],
+            ["supergroup", -10012345678901, None, True, "https://t.me/c/12345678901/10"],
+        ],
+    )
+    def test_method(
+        self,
+        bot: MockedBot,
+        chat_type: str,
+        chat_id: int,
+        chat_username: Optional[str],
+        force_private: bool,
+        expected_result: Optional[str],
+    ):
+
+        fake_chat = Chat(id=chat_id, username=chat_username, type=chat_type)
+        fake_message_id = 10
+        fake_message = Message(
+            message_id=fake_message_id,
+            date=datetime.datetime.now(),
+            text="test",
+            chat=fake_chat,
+        )
+
+        if expected_result is None:
+            assert fake_message.get_url(force_private=force_private) is None
+        else:
+            assert fake_message.get_url(force_private=force_private) == expected_result