Repository: opsdroid/opsdroid

Issue:
Slack Connector, search a channel ID, by its name
<!-- Before you post an issue or if you are unsure about something join our matrix channel https://riot.im/app/#/room/#opsdroid-general:matrix.org and ask away! We are more than happy to help you. -->
# Description
Currently, if we need to send any message to a Slack Channel, we have to know the channel ID. Unfortunately, this is not always possible, and it would be nice to have a way that Opsdroid translates channel names to channel ids.

I have been searching on the Slack API and there is no method that would do this, so the only thing I can think of, is iterating over all the channels. (probably when Opsdroid starts the connector?)  https://api.slack.com/methods/conversations.list
I would like to submit a PR for this, and I would love your thoughts, suggestions on how could this be better implemented.

## Steps to Reproduce
N/A


## Expected Functionality
```
await self.opsdroid.send(Message("Hello", target="test-channel"))
```

## Experienced Functionality
This will throw an error:
```
await self.opsdroid.send(Message("Hello", target="test-channel"))
```
Only works when channel ID is mentioned:
```
await self.opsdroid.send(Message("Hello", target="CF3ZP8KS8"))
```


## Versions
- **Opsdroid version:** All versions
- **Python version:** All Versions 
- **OS/Docker version:** All Versions




<!-- Love opsdroid? Please consider supporting our collective:
 +👉  https://opencollective.com/opsdroid/donate -->



Discussion:
Does this work for you?

```python
await self.opsdroid.send(Message("Hello", target="#test-channel"))
```
I think this behavior has changed very recently on the Slack API.  (Unless opsdroid is doing something in the background that I was not able to catch)

The following methods are working now:
```
await self.opsdroid.send(Message("Hello", target="test-channel"))
await self.opsdroid.send(Message("Hello", target="#test-channel"))
```

I am saying very recently because this has been an issue that I have been trying to tackle for a while, and it was not working on the past.

I see that not even the documentation of https://api.slack.com/methods/chat.postMessage#channels has been updated yet

> Post to a public channel
> Pass the channel's ID (C024BE91L) to the channel parameter and the message will be posted to that channel. The channel's ID can be retrieved through the conversations.list API method.

I am not sure if this issue is relevant now, I have found another method, with the same problem (https://api.slack.com/methods/conversations.setTopic) where the ID is required, and the channel name won't work

Does it make sense for opsdroid to have a channel list, with its channel ids and other details? 
I've always used the `#` prefixed version with no issues.

I don't think opsdroid supports `setTopic` right now. But yes I see that this could be a problem in other places.

I would be very happy to review a PR which grabs the channel list and automatically converts names to IDs as part of the send methods.

Resolving patch:
diff --git a/opsdroid/connector/slack/connector.py b/opsdroid/connector/slack/connector.py
--- a/opsdroid/connector/slack/connector.py
+++ b/opsdroid/connector/slack/connector.py
@@ -5,13 +5,14 @@
 import os
 import re
 import ssl
+import time
 import urllib.parse
 
 import aiohttp
+import arrow
 import certifi
 from emoji import demojize
 
-
 from slack_sdk.errors import SlackApiError
 from slack_sdk.socket_mode.aiohttp import SocketModeClient
 from slack_sdk.socket_mode.request import SocketModeRequest
@@ -19,7 +20,6 @@
 from slack_sdk.web.async_client import AsyncWebClient
 from voluptuous import Required
 
-
 import opsdroid.events
 from opsdroid.connector import Connector, register_event
 from opsdroid.connector.slack.create_events import SlackEventCreator
@@ -48,6 +48,8 @@
     "default-room": str,
     "icon-emoji": str,
     "start-thread": bool,
+    "refresh-interval": int,
+    "channel-limit": int,
 }
 
 
@@ -66,6 +68,8 @@ def __init__(self, config, opsdroid=None):
         self.start_thread = config.get("start-thread", False)
         self.socket_mode = config.get("socket-mode", True)
         self.app_token = config.get("app-token")
+        self.channel_limit = config.get("channel-limit", 100)
+        self.refresh_interval = config.get("refresh-interval", 600)
         self.ssl_context = ssl.create_default_context(cafile=certifi.where())
         self.slack_web_client = AsyncWebClient(
             token=self.bot_token,
@@ -81,6 +85,7 @@ def __init__(self, config, opsdroid=None):
         self.user_info = None
         self.bot_id = None
         self.known_users = {}
+        self.known_channels = {}
 
         self._event_creator = SlackEventCreator(self)
         self._event_queue = asyncio.Queue()
@@ -108,6 +113,7 @@ async def connect(self):
                 )
             ).data
             self.bot_id = self.user_info["user"]["profile"]["bot_id"]
+            self.opsdroid.create_task(self._get_channels())
         except SlackApiError as error:
             _LOGGER.error(
                 _(
@@ -191,6 +197,28 @@ def _generate_base_data(self, event: opsdroid.events.Event) -> dict:
 
         return data
 
+    async def _get_channels(self):
+        """Grab all the channels from the Slack API"""
+
+        while self.opsdroid.eventloop.is_running():
+            _LOGGER.info(_("Updating Channels from Slack API at %s."), time.asctime())
+            channels = await self.slack_web_client.conversations_list(
+                limit=self.channel_limit
+            )
+            cursor = channels["response_metadata"].get("next_cursor")
+
+            while cursor:
+                self.known_channels.update({c["name"]: c for c in channels["channels"]})
+
+                channels = await self.slack_web_client.conversations_list(
+                    cursor=cursor, limit=self.channel_limit
+                )
+                cursor = channels["response_metadata"].get("next_cursor")
+
+            channel_count = len(self.known_channels.keys())
+            _LOGGER.info("Grabbed a total of %s channels from Slack", channel_count)
+            await asyncio.sleep(self.refresh_interval - arrow.now().time().second)
+
     async def event_handler(self, payload):
         """Handle different payload types and parse the resulting events"""
 
@@ -276,6 +304,36 @@ async def web_event_handler(self, request):
 
         return aiohttp.web.Response(text=json.dumps("Received"), status=200)
 
+    async def find_channel(self, channel_name):
+        """
+        Given a channel name return the channel properties.
+
+        args:
+            channel_name: the name of the channel. ie: general
+
+        returns:
+            dict with channel details
+
+        **Basic Usage Example in a Skill:**
+
+        .. code-block:: python
+
+            from opsdroid.skill import Skill
+            from opsdroid.matchers import match_regex
+
+            class SearchMessagesSkill(Skill):
+                @match_regex(r"find channel")
+                async def find_channel(self, message):
+                    """ """
+                    slack = self.opsdroid.get_connector("slack")
+                    channel = await slack.find_channel(channel_name="general")
+                    await message.respond(str(channel))
+        """
+
+        if channel_name in self.known_channels:
+            return self.known_channels[channel_name]
+        _LOGGER.info(_("Channel with name %s not found"), channel_name)
+
     async def search_history_messages(self, channel, start_time, end_time, limit=100):
         """
         Search for messages in a conversation given the intial and end timestamp.
@@ -304,7 +362,7 @@ async def search_messages(self, message):
                     messages = await slack.search_history_messages(
                         "CHANEL_ID", start_time="1512085950.000216", end_time="1512104434.000490"
                     )
-                    await message.respond(messages)
+                    await message.respond(str(messages))
         """
         messages = []
         history = await self.slack_web_client.conversations_history(


Test patch:
diff --git a/opsdroid/connector/slack/tests/responses/method_conversations.list_first_page.json b/opsdroid/connector/slack/tests/responses/method_conversations.list_first_page.json
new file mode 100644
--- /dev/null
+++ b/opsdroid/connector/slack/tests/responses/method_conversations.list_first_page.json
@@ -0,0 +1,74 @@
+{
+    "ok": true,
+    "channels": [
+        {
+            "id": "C012AB3CD",
+            "name": "general",
+            "is_channel": true,
+            "is_group": false,
+            "is_im": false,
+            "created": 1449252889,
+            "creator": "U012A3CDE",
+            "is_archived": false,
+            "is_general": true,
+            "unlinked": 0,
+            "name_normalized": "general",
+            "is_shared": false,
+            "is_ext_shared": false,
+            "is_org_shared": false,
+            "pending_shared": [],
+            "is_pending_ext_shared": false,
+            "is_member": true,
+            "is_private": false,
+            "is_mpim": false,
+            "topic": {
+                "value": "Company-wide announcements and work-based matters",
+                "creator": "",
+                "last_set": 0
+            },
+            "purpose": {
+                "value": "This channel is for team-wide communication and announcements. All team members are in this channel.",
+                "creator": "",
+                "last_set": 0
+            },
+            "previous_names": [],
+            "num_members": 4
+        },
+        {
+            "id": "C061EG9T2",
+            "name": "random",
+            "is_channel": true,
+            "is_group": false,
+            "is_im": false,
+            "created": 1449252889,
+            "creator": "U061F7AUR",
+            "is_archived": false,
+            "is_general": false,
+            "unlinked": 0,
+            "name_normalized": "random",
+            "is_shared": false,
+            "is_ext_shared": false,
+            "is_org_shared": false,
+            "pending_shared": [],
+            "is_pending_ext_shared": false,
+            "is_member": true,
+            "is_private": false,
+            "is_mpim": false,
+            "topic": {
+                "value": "Non-work banter and water cooler conversation",
+                "creator": "",
+                "last_set": 0
+            },
+            "purpose": {
+                "value": "A place for non-work-related flimflam, faffing, hodge-podge or jibber-jabber you'd prefer to keep out of more focused work-related channels.",
+                "creator": "",
+                "last_set": 0
+            },
+            "previous_names": [],
+            "num_members": 4
+        }
+    ],
+    "response_metadata": {
+        "next_cursor": "dGVhbTpDMDYxRkE1UEI="
+    }
+}
diff --git a/opsdroid/connector/slack/tests/responses/method_conversations.list_last_page.json b/opsdroid/connector/slack/tests/responses/method_conversations.list_last_page.json
new file mode 100644
--- /dev/null
+++ b/opsdroid/connector/slack/tests/responses/method_conversations.list_last_page.json
@@ -0,0 +1,72 @@
+{
+	"ok": true,
+	"channels": [
+		{
+			"id": "C012AB3CD",
+			"name": "general",
+			"is_channel": true,
+			"is_group": false,
+			"is_im": false,
+			"created": 1449252889,
+			"creator": "U012A3CDE",
+			"is_archived": false,
+			"is_general": true,
+			"unlinked": 0,
+			"name_normalized": "general",
+			"is_shared": false,
+			"is_ext_shared": false,
+			"is_org_shared": false,
+			"pending_shared": [],
+			"is_pending_ext_shared": false,
+			"is_member": true,
+			"is_private": false,
+			"is_mpim": false,
+			"topic": {
+				"value": "Company-wide announcements and work-based matters",
+				"creator": "",
+				"last_set": 0
+			},
+			"purpose": {
+				"value": "This channel is for team-wide communication and announcements. All team members are in this channel.",
+				"creator": "",
+				"last_set": 0
+			},
+			"previous_names": [],
+			"num_members": 4
+		},
+		{
+			"id": "C061EG9T2",
+			"name": "random",
+			"is_channel": true,
+			"is_group": false,
+			"is_im": false,
+			"created": 1449252889,
+			"creator": "U061F7AUR",
+			"is_archived": false,
+			"is_general": false,
+			"unlinked": 0,
+			"name_normalized": "random",
+			"is_shared": false,
+			"is_ext_shared": false,
+			"is_org_shared": false,
+			"pending_shared": [],
+			"is_pending_ext_shared": false,
+			"is_member": true,
+			"is_private": false,
+			"is_mpim": false,
+			"topic": {
+				"value": "Non-work banter and water cooler conversation",
+				"creator": "",
+				"last_set": 0
+			},
+			"purpose": {
+				"value": "A place for non-work-related flimflam, faffing, hodge-podge or jibber-jabber you'd prefer to keep out of more focused work-related channels.",
+				"creator": "",
+				"last_set": 0
+			},
+			"previous_names": [],
+			"num_members": 4
+		}
+	],
+	"response_metadata": {"next_cursor": ""}
+}
diff --git a/opsdroid/connector/slack/tests/test_connector.py b/opsdroid/connector/slack/tests/test_connector.py
--- a/opsdroid/connector/slack/tests/test_connector.py
+++ b/opsdroid/connector/slack/tests/test_connector.py
@@ -12,6 +12,7 @@
     ModalPush,
     ModalUpdate,
 )
+
 from slack_sdk.socket_mode.request import SocketModeRequest
 
 from .conftest import get_path
@@ -30,6 +31,12 @@
     get_path("method_conversations.history.json"),
     200,
 )
+CONVERSATIONS_LIST_LAST_PAGE = (
+    "/conversations.list",
+    "GET",
+    get_path("method_conversations.list_last_page.json"),
+    200,
+)
 CONVERSATIONS_CREATE = ("/conversations.create", "POST", {"ok": True}, 200)
 CONVERSATIONS_RENAME = ("/conversations.rename", "POST", {"ok": True}, 200)
 CONVERSATIONS_JOIN = ("/conversations.join", "POST", {"ok": True}, 200)
@@ -188,6 +195,26 @@ async def test_search_history_messages_more_than_one_api_request(connector, mock
     assert isinstance(history, list)
 
 
+@pytest.mark.asyncio
+@pytest.mark.add_response(*CONVERSATIONS_LIST_LAST_PAGE)
+async def test_find_channel(connector, mock_api):
+    connector.known_channels = {"general": {"name": "general", "id": "C012AB3CD"}}
+
+    channel = await connector.find_channel("general")
+    assert channel["id"] == "C012AB3CD"
+    assert channel["name"] == "general"
+
+
+@pytest.mark.asyncio
+@pytest.mark.add_response(*CONVERSATIONS_LIST_LAST_PAGE)
+async def test_find_channel_not_found(connector, mock_api, caplog):
+    connector.known_channels = {"general": {"name": "general", "id": "C012AB3CD"}}
+
+    caplog.set_level(logging.INFO)
+    await connector.find_channel("another-channel")
+    assert "Channel with name another-channel not found" in caplog.text
+
+
 @pytest.mark.asyncio
 async def test_replace_usernames(connector):
     connector.known_users = {"U01NK1K9L68": {"name": "Test User"}}
diff --git a/opsdroid/connector/slack/tests/test_create_events.py b/opsdroid/connector/slack/tests/test_create_events.py
--- a/opsdroid/connector/slack/tests/test_create_events.py
+++ b/opsdroid/connector/slack/tests/test_create_events.py
@@ -4,7 +4,6 @@
 import json
 
 import pytest
-
 from opsdroid.connector.slack.create_events import SlackEventCreator
 from opsdroid.testing import MINIMAL_CONFIG, call_endpoint, run_unit_test
 
@@ -26,35 +25,29 @@ def get_webhook_payload(file_name, _type):
 
 
 CONNECTOR_ENDPOINT = "/connector/slack"
+
+# The following are sample responses from the Slack API, when the Slack connector is initializing
 USERS_INFO = ("/users.info", "GET", get_path("method_users.info.json"), 200)
 AUTH_TEST = ("/auth.test", "POST", get_path("method_auth.test.json"), 200)
+CONVERSATIONS_LIST_LAST_PAGE = (
+    "/conversations.list",
+    "GET",
+    get_path("method_conversations.list_last_page.json"),
+    200,
+)
+CONVERSATIONS_LIST_FIRST_PAGE = (
+    "/conversations.list",
+    "GET",
+    get_path("method_conversations.list_first_page.json"),
+    200,
+)
 
 
 @pytest.mark.asyncio
-@pytest.mark.add_response(*USERS_INFO)
-@pytest.mark.add_response(*AUTH_TEST)
-async def test_receive_url_verification(opsdroid, connector, mock_api):
-    await opsdroid.load(config=MINIMAL_CONFIG)
-
-    async def receive_message_action():
-        headers, data = get_webhook_payload("payload_url_verification.json", "json")
-        resp = await call_endpoint(
-            opsdroid,
-            CONNECTOR_ENDPOINT,
-            "POST",
-            data=data,
-            headers=headers,
-        )
-        assert resp.status == 200
-
-        return True
-
-    assert await run_unit_test(opsdroid, receive_message_action)
-
-
 @pytest.mark.parametrize(
     "payload",
     [
+        "payload_url_verification.json",
         "payload_message.json",
         "payload_message_bot_message.json",
         "payload_message_message_changed.json",
@@ -66,12 +59,21 @@ async def receive_message_action():
         "payload_channel_rename.json",
         "payload_pin_added.json",
         "payload_pin_removed.json",
+        "payload_block_action_button.urlencoded",
+        "payload_block_action_overflow.urlencoded",
+        "payload_block_action_static_select.urlencoded",
+        "payload_block_action_datepicker.urlencoded",
+        "payload_block_action_multi_static_select.urlencoded",
+        "payload_message_action.urlencoded",
+        "payload_slash_command.urlencoded",
+        "payload_view_submission.urlencoded",
     ],
 )
 @pytest.mark.add_response(*USERS_INFO)
 @pytest.mark.add_response(*AUTH_TEST)
-@pytest.mark.asyncio
-async def test_receive_event_callback(opsdroid, connector, mock_api, payload):
+@pytest.mark.add_response(*CONVERSATIONS_LIST_LAST_PAGE)
+@pytest.mark.add_response(*CONVERSATIONS_LIST_FIRST_PAGE)
+async def test_events_from_slack(opsdroid, connector, mock_api, payload):
     """Mocks receiving a payload event from the Slack API.
     Run unit test against an opsdroid instance
     parameter:
@@ -86,64 +88,9 @@ async def test_receive_event_callback(opsdroid, connector, mock_api, payload):
     }
 
     async def receive_event(payload):
-        headers, data = get_webhook_payload(payload, "json")
-        resp = await call_endpoint(
-            opsdroid,
-            CONNECTOR_ENDPOINT,
-            "POST",
-            data=data,
-            headers=headers,
-        )
-
-        assert resp.status == 200
-
-        return True
-
-    assert await run_unit_test(opsdroid, receive_event, payload)
-
-
-@pytest.mark.parametrize(
-    "payload",
-    [
-        "payload_block_action_button.urlencoded",
-        "payload_block_action_overflow.urlencoded",
-        "payload_block_action_static_select.urlencoded",
-        "payload_block_action_datepicker.urlencoded",
-        "payload_block_action_multi_static_select.urlencoded",
-    ],
-)
-@pytest.mark.add_response(*USERS_INFO)
-@pytest.mark.add_response(*AUTH_TEST)
-@pytest.mark.asyncio
-async def test_receive_block_action(opsdroid, connector, mock_api, payload):
-    await opsdroid.load(config=MINIMAL_CONFIG)
-
-    async def receive_block_action(payload):
-        headers, data = get_webhook_payload(payload, "urlencoded")
-        resp = await call_endpoint(
-            opsdroid,
-            CONNECTOR_ENDPOINT,
-            "POST",
-            data=data,
-            headers=headers,
-        )
-        assert resp.status == 200
+        payload_type = payload.split(".")[1]
 
-        return True
-
-    assert await run_unit_test(opsdroid, receive_block_action, payload)
-
-
-@pytest.mark.asyncio
-@pytest.mark.add_response(*USERS_INFO)
-@pytest.mark.add_response(*AUTH_TEST)
-async def test_receive_message_action(opsdroid, connector, mock_api):
-    await opsdroid.load(config=MINIMAL_CONFIG)
-
-    async def receive_message_action():
-        headers, data = get_webhook_payload(
-            "payload_message_action.urlencoded", "urlencoded"
-        )
+        headers, data = get_webhook_payload(payload, payload_type)
         resp = await call_endpoint(
             opsdroid,
             CONNECTOR_ENDPOINT,
@@ -155,76 +102,7 @@ async def receive_message_action():
 
         return True
 
-    assert await run_unit_test(opsdroid, receive_message_action)
-
-
-@pytest.mark.asyncio
-@pytest.mark.add_response(*USERS_INFO)
-@pytest.mark.add_response(*AUTH_TEST)
-async def test_receive_slash_command(opsdroid, connector, mock_api):
-    await opsdroid.load(config=MINIMAL_CONFIG)
-
-    async def receive_slash_command():
-        headers, data = get_webhook_payload(
-            "payload_slash_command.urlencoded", "urlencoded"
-        )
-        resp = await call_endpoint(
-            opsdroid,
-            CONNECTOR_ENDPOINT,
-            "POST",
-            data=data,
-            headers=headers,
-        )
-        assert resp.status == 200
-
-        return True
-
-    assert await run_unit_test(opsdroid, receive_slash_command)
-
-
-@pytest.mark.asyncio
-@pytest.mark.add_response(*USERS_INFO)
-@pytest.mark.add_response(*AUTH_TEST)
-async def test_receive_view_submission(opsdroid, connector, mock_api):
-    await opsdroid.load(config=MINIMAL_CONFIG)
-
-    async def receive_view_submission():
-        headers, data = get_webhook_payload(
-            "payload_view_submission.urlencoded", "urlencoded"
-        )
-        resp = await call_endpoint(
-            opsdroid,
-            CONNECTOR_ENDPOINT,
-            "POST",
-            data=data,
-            headers=headers,
-        )
-        assert resp.status == 200
-
-        return True
-
-    assert await run_unit_test(opsdroid, receive_view_submission)
-
-
-@pytest.mark.asyncio
-@pytest.mark.add_response(*USERS_INFO)
-@pytest.mark.add_response(*AUTH_TEST)
-async def test_receive_unknown_payload(opsdroid, connector, mock_api, caplog):
-    await opsdroid.load(config=MINIMAL_CONFIG)
-
-    async def receive_message_action():
-        resp = await call_endpoint(
-            opsdroid,
-            CONNECTOR_ENDPOINT,
-            "POST",
-            data=json.dumps({"type": "another_unknown type"}),
-            headers={"content-type": "application/json"},
-        )
-        assert resp.status == 200
-
-        return True
-
-    assert await run_unit_test(opsdroid, receive_message_action)
+    assert await run_unit_test(opsdroid, receive_event, payload)
 
 
 @pytest.mark.asyncio