aboutsummaryrefslogtreecommitdiffstats
path: root/slack
diff options
context:
space:
mode:
Diffstat (limited to 'slack')
-rw-r--r--slack/api.py84
-rw-r--r--slack/config.py320
-rw-r--r--slack/globals.py18
-rw-r--r--slack/log.py23
-rw-r--r--slack/main.py49
-rw-r--r--slack/task.py79
-rw-r--r--slack/weechat_http.py105
7 files changed, 678 insertions, 0 deletions
diff --git a/slack/api.py b/slack/api.py
new file mode 100644
index 0000000..1903718
--- /dev/null
+++ b/slack/api.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING, Any, Dict, Union
+from urllib.parse import urlencode
+
+import globals
+from weechat_http import http_request
+
+if TYPE_CHECKING:
+ from slack_api import SlackConversation, SlackConversationIm, SlackConversationNotIm
+else:
+ # To support running without slack types
+ SlackConversation = Any
+ SlackConversationNotIm = Any
+ SlackConversationIm = Any
+
+
+class SlackApi:
+ def __init__(self, workspace: SlackWorkspace):
+ self.workspace = workspace
+
+ def get_request_options(self):
+ return {
+ "useragent": f"wee_slack {globals.SCRIPT_VERSION}",
+ "httpheader": f"Authorization: Bearer {self.workspace.config.api_token.value}",
+ "cookie": self.workspace.config.api_cookies.value,
+ }
+
+ async def fetch(self, method: str, params: Dict[str, Union[str, int]] = {}):
+ url = f"https://api.slack.com/api/{method}?{urlencode(params)}"
+ response = await http_request(
+ url,
+ self.get_request_options(),
+ self.workspace.config.slack_timeout.value * 1000,
+ )
+ return json.loads(response)
+
+ async def fetch_list(
+ self,
+ method: str,
+ list_key: str,
+ params: Dict[str, Union[str, int]] = {},
+ pages: int = 1, # negative or 0 means all pages
+ ):
+ response = await self.fetch(method, params)
+ next_cursor = response.get("response_metadata", {}).get("next_cursor")
+ if pages != 1 and next_cursor and response["ok"]:
+ params["cursor"] = next_cursor
+ next_pages = await self.fetch_list(method, list_key, params, pages - 1)
+ response[list_key].extend(next_pages[list_key])
+ return response
+ return response
+
+
+class SlackWorkspace:
+ def __init__(self, name: str):
+ self.name = name
+ self.config = globals.config.create_workspace_config(self.name)
+ self.api = SlackApi(self)
+
+
+class SlackChannelCommonNew:
+ def __init__(self, workspace: SlackWorkspace, slack_info: SlackConversation):
+ self.workspace = workspace
+ self.api = workspace.api
+ self.id = slack_info["id"]
+ # self.fetch_info()
+
+ async def fetch_info(self):
+ response = await self.api.fetch("conversations.info", {"channel": self.id})
+ print(len(response))
+
+
+class SlackChannelNew(SlackChannelCommonNew):
+ def __init__(self, workspace: SlackWorkspace, slack_info: SlackConversationNotIm):
+ super().__init__(workspace, slack_info)
+ self.name = slack_info["name"]
+
+
+class SlackIm(SlackChannelCommonNew):
+ def __init__(self, workspace: SlackWorkspace, slack_info: SlackConversationIm):
+ super().__init__(workspace, slack_info)
+ self.user = slack_info["user"]
diff --git a/slack/config.py b/slack/config.py
new file mode 100644
index 0000000..34c70cf
--- /dev/null
+++ b/slack/config.py
@@ -0,0 +1,320 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Generic, TypeVar, Union, cast
+
+import globals
+import weechat
+from api import SlackWorkspace
+from log import print_error
+
+
+class WeeChatColor(str):
+ pass
+
+
+@dataclass
+class WeeChatConfig:
+ name: str
+
+ def __post_init__(self):
+ self.pointer = weechat.config_new(self.name, "", "")
+
+
+@dataclass
+class WeeChatSection:
+ weechat_config: WeeChatConfig
+ name: str
+ user_can_add_options: bool = False
+ user_can_delete_options: bool = False
+ callback_read: str = ""
+ callback_write: str = ""
+
+ def __post_init__(self):
+ self.pointer = weechat.config_new_section(
+ self.weechat_config.pointer,
+ self.name,
+ self.user_can_add_options,
+ self.user_can_delete_options,
+ self.callback_read,
+ "",
+ self.callback_write,
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ )
+
+
+WeeChatOptionType = TypeVar("WeeChatOptionType", bound=Union[int, str])
+
+
+@dataclass
+class WeeChatOption(Generic[WeeChatOptionType]):
+ section: WeeChatSection
+ name: str
+ description: str
+ default_value: WeeChatOptionType
+ min_value: Union[int, None] = None
+ max_value: Union[int, None] = None
+ string_values: Union[str, None] = None
+ parent_option: Union[WeeChatOption[WeeChatOptionType], None] = None
+
+ def __post_init__(self):
+ self._pointer = self._create_weechat_option()
+
+ @property
+ def value(self) -> WeeChatOptionType:
+ if weechat.config_option_is_null(self._pointer):
+ if self.parent_option:
+ return self.parent_option.value
+ return self.default_value
+
+ if isinstance(self.default_value, bool):
+ return cast(WeeChatOptionType, weechat.config_boolean(self._pointer) == 1)
+ if isinstance(self.default_value, int):
+ return cast(WeeChatOptionType, weechat.config_integer(self._pointer))
+ if isinstance(self.default_value, WeeChatColor):
+ color = weechat.config_color(self._pointer)
+ return cast(WeeChatOptionType, WeeChatColor(color))
+ return cast(WeeChatOptionType, weechat.config_string(self._pointer))
+
+ @value.setter
+ def value(self, value: WeeChatOptionType):
+ rc = self.value_set_as_str(str(value))
+ if rc == weechat.WEECHAT_CONFIG_OPTION_SET_ERROR:
+ raise Exception(f"Failed to value for option: {self.name}")
+
+ def value_set_as_str(self, value: str) -> int:
+ return weechat.config_option_set(self._pointer, value, 1)
+
+ def value_set_null(self) -> int:
+ if not self.parent_option:
+ raise Exception(
+ f"Can't set null value for option without parent: {self.name}"
+ )
+ return weechat.config_option_set_null(self._pointer, 1)
+
+ @property
+ def weechat_type(self) -> str:
+ if self.string_values:
+ return "integer"
+ if isinstance(self.default_value, bool):
+ return "boolean"
+ if isinstance(self.default_value, int):
+ return "integer"
+ if isinstance(self.default_value, WeeChatColor):
+ return "color"
+ return "string"
+
+ def _create_weechat_option(self) -> str:
+ if self.parent_option:
+ parent_option_name = (
+ f"{self.parent_option.section.weechat_config.name}"
+ f".{self.parent_option.section.name}"
+ f".{self.parent_option.name}"
+ )
+ name = f"{self.name} << {parent_option_name}"
+ default_value = None
+ null_value_allowed = True
+ else:
+ name = self.name
+ default_value = str(self.default_value)
+ null_value_allowed = False
+
+ value = None
+
+ if globals.weechat_version < 0x3050000:
+ default_value = str(self.default_value)
+ value = default_value
+
+ return weechat.config_new_option(
+ self.section.weechat_config.pointer,
+ self.section.pointer,
+ name,
+ self.weechat_type,
+ self.description,
+ self.string_values or "",
+ self.min_value or -(2**31),
+ self.max_value or 2**31 - 1,
+ default_value,
+ value,
+ null_value_allowed,
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ )
+
+
+class SlackConfigSectionColor:
+ def __init__(self, weechat_config: WeeChatConfig):
+ self._section = WeeChatSection(weechat_config, "color")
+
+ self.reaction_suffix = WeeChatOption(
+ self._section,
+ "reaction_suffix",
+ "Color to use for the [:wave:(@user)] suffix on messages that have "
+ "reactions attached to them.",
+ WeeChatColor("darkgray"),
+ )
+
+
+class SlackConfigSectionWorkspace:
+ def __init__(
+ self,
+ section: WeeChatSection,
+ workspace_name: Union[str, None],
+ parent_config: Union[SlackConfigSectionWorkspace, None],
+ ):
+ self._section = section
+ self._workspace_name = workspace_name
+ self._parent_config = parent_config
+
+ self.api_token = self._create_option(
+ "api_token",
+ "",
+ "",
+ )
+
+ self.api_cookies = self._create_option(
+ "api_cookies",
+ "",
+ "",
+ )
+
+ self.slack_timeout = self._create_option(
+ "slack_timeout",
+ "timeout (in seconds) for network requests",
+ 30,
+ )
+
+ def _create_option(
+ self,
+ name: str,
+ description: str,
+ default_value: WeeChatOptionType,
+ min_value: Union[int, None] = None,
+ max_value: Union[int, None] = None,
+ string_values: Union[str, None] = None,
+ ) -> WeeChatOption[WeeChatOptionType]:
+ if self._workspace_name:
+ option_name = f"{self._workspace_name}.{name}"
+ else:
+ option_name = name
+
+ if self._parent_config:
+ parent_option = getattr(self._parent_config, name, None)
+ else:
+ parent_option = None
+
+ return WeeChatOption(
+ self._section,
+ option_name,
+ description,
+ default_value,
+ min_value,
+ max_value,
+ string_values,
+ parent_option,
+ )
+
+
+def config_section_workspace_read_cb(
+ data: str, config_file: str, section: str, option_name: str, value: Union[str, None]
+) -> int:
+ option_split = option_name.split(".", 1)
+ if len(option_split) < 2:
+ return weechat.WEECHAT_CONFIG_OPTION_SET_ERROR
+ workspace_name, name = option_split
+ if not workspace_name or not name:
+ return weechat.WEECHAT_CONFIG_OPTION_SET_ERROR
+
+ if workspace_name not in globals.workspaces:
+ globals.workspaces[workspace_name] = SlackWorkspace(workspace_name)
+
+ option = getattr(globals.workspaces[workspace_name].config, name, None)
+ if option is None:
+ return weechat.WEECHAT_CONFIG_OPTION_SET_OPTION_NOT_FOUND
+ if not isinstance(option, WeeChatOption):
+ return weechat.WEECHAT_CONFIG_OPTION_SET_ERROR
+
+ if value is None or (
+ globals.weechat_version < 0x3080000
+ and value == ""
+ and option.weechat_type != "string"
+ ):
+ rc = option.value_set_null()
+ else:
+ rc = option.value_set_as_str(value)
+ if rc == weechat.WEECHAT_CONFIG_OPTION_SET_ERROR:
+ print_error(f'error creating workspace option "{option_name}"')
+ return rc
+
+
+def config_section_workspace_write_for_old_weechat_cb(
+ data: str, config_file: str, section_name: str
+) -> int:
+ if not weechat.config_write_line(config_file, section_name, ""):
+ return weechat.WEECHAT_CONFIG_WRITE_ERROR
+
+ for workspace in globals.workspaces.values():
+ for option in vars(workspace.config).values():
+ if isinstance(option, WeeChatOption):
+ if (
+ option.weechat_type != "string"
+ or not weechat.config_option_is_null(
+ option._pointer # pyright: ignore [reportPrivateUsage]
+ )
+ ):
+ if not weechat.config_write_option(
+ config_file,
+ option._pointer, # pyright: ignore [reportPrivateUsage]
+ ):
+ return weechat.WEECHAT_CONFIG_WRITE_ERROR
+
+ return weechat.WEECHAT_CONFIG_WRITE_OK
+
+
+class SlackConfig:
+ def __init__(self):
+ self.weechat_config = WeeChatConfig("slack")
+ self.color = SlackConfigSectionColor(self.weechat_config)
+ self._section_workspace_default = WeeChatSection(
+ self.weechat_config, "workspace_default"
+ )
+ # WeeChat < 3.8 sends null as an empty string to callback_read, so in
+ # order to distinguish them, don't write the null values to the config
+ # See https://github.com/weechat/weechat/pull/1843
+ print("version", globals.weechat_version)
+ callback_write = (
+ config_section_workspace_write_for_old_weechat_cb.__name__
+ if globals.weechat_version < 0x3080000
+ else ""
+ )
+ self._section_workspace = WeeChatSection(
+ self.weechat_config,
+ "workspace",
+ callback_read=config_section_workspace_read_cb.__name__,
+ callback_write=callback_write,
+ )
+ self._workspace_default = SlackConfigSectionWorkspace(
+ self._section_workspace_default, None, None
+ )
+
+ def config_read(self):
+ weechat.config_read(self.weechat_config.pointer)
+
+ def create_workspace_config(self, workspace_name: str):
+ if workspace_name in globals.workspaces:
+ raise Exception(
+ f"Failed to create workspace config, already exists: {workspace_name}"
+ )
+ return SlackConfigSectionWorkspace(
+ self._section_workspace, workspace_name, self._workspace_default
+ )
diff --git a/slack/globals.py b/slack/globals.py
new file mode 100644
index 0000000..30ffbd1
--- /dev/null
+++ b/slack/globals.py
@@ -0,0 +1,18 @@
+from typing import Any, Dict, Tuple
+
+from api import SlackWorkspace
+from config import SlackConfig
+from task import Task
+
+SCRIPT_NAME = "slack"
+SCRIPT_AUTHOR = "Trygve Aaberge <trygveaa@gmail.com>"
+SCRIPT_VERSION = "3.0.0"
+SCRIPT_LICENSE = "MIT"
+SCRIPT_DESC = "Extends weechat for typing notification/search/etc on slack.com"
+REPO_URL = "https://github.com/wee-slack/wee-slack"
+
+weechat_version: int
+active_tasks: Dict[str, Task[Any]] = {}
+active_responses: Dict[str, Tuple[Any, ...]] = {}
+workspaces: Dict[str, SlackWorkspace] = {}
+config: SlackConfig
diff --git a/slack/log.py b/slack/log.py
new file mode 100644
index 0000000..f65a095
--- /dev/null
+++ b/slack/log.py
@@ -0,0 +1,23 @@
+from enum import IntEnum
+
+import globals
+import weechat
+
+
+class LogLevel(IntEnum):
+ TRACE = 1
+ DEBUG = 2
+ INFO = 3
+ WARN = 4
+ ERROR = 5
+ FATAL = 6
+
+
+# TODO: Figure out what to do with print_error vs log
+def print_error(message: str):
+ weechat.prnt("", f"{weechat.prefix('error')}{globals.SCRIPT_NAME}: {message}")
+
+
+def log(level: LogLevel, message: str):
+ if level >= LogLevel.INFO:
+ print(level, message)
diff --git a/slack/main.py b/slack/main.py
new file mode 100644
index 0000000..38cd167
--- /dev/null
+++ b/slack/main.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+import os
+import sys
+
+import weechat
+
+sys.path.append(os.path.dirname(os.path.realpath(__file__)))
+import globals # pylint: disable=wrong-import-position
+from config import SlackConfig, SlackWorkspace # pylint: disable=wrong-import-position
+from task import create_task # pylint: disable=wrong-import-position
+
+
+def shutdown_cb():
+ weechat.config_write(globals.config.weechat_config.pointer)
+ return weechat.WEECHAT_RC_OK
+
+
+async def init():
+ print(globals.workspaces)
+ if "wee-slack-test" not in globals.workspaces:
+ globals.workspaces["wee-slack-test"] = SlackWorkspace("wee-slack-test")
+ globals.workspaces[
+ "wee-slack-test"
+ ].config.api_token.value = weechat.config_get_plugin("api_token")
+ globals.workspaces[
+ "wee-slack-test"
+ ].config.api_cookies.value = weechat.config_get_plugin("api_cookie")
+ workspace = globals.workspaces["wee-slack-test"]
+ print(workspace)
+ print(workspace.config.slack_timeout.value)
+ print(globals.config.color.reaction_suffix.value)
+
+
+if __name__ == "__main__":
+ if weechat.register(
+ globals.SCRIPT_NAME,
+ globals.SCRIPT_AUTHOR,
+ globals.SCRIPT_VERSION,
+ globals.SCRIPT_LICENSE,
+ globals.SCRIPT_DESC,
+ shutdown_cb.__name__,
+ "",
+ ):
+ globals.weechat_version = int(weechat.info_get("version_number", "") or 0)
+ globals.workspaces = {}
+ globals.config = SlackConfig()
+ globals.config.config_read()
+ create_task(init(), final=True)
diff --git a/slack/task.py b/slack/task.py
new file mode 100644
index 0000000..e976866
--- /dev/null
+++ b/slack/task.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from typing import Any, Awaitable, Coroutine, Generator, Tuple, TypeVar
+from uuid import uuid4
+
+import globals
+import weechat
+
+T = TypeVar("T")
+
+
+class Future(Awaitable[T]):
+ def __init__(self):
+ self.id = str(uuid4())
+
+ def __await__(self) -> Generator[Future[T], T, T]:
+ return (yield self)
+
+
+class FutureProcess(Future[Tuple[str, int, str, str]]):
+ pass
+
+
+class FutureTimer(Future[Tuple[int]]):
+ pass
+
+
+class Task(Future[T]):
+ def __init__(self, coroutine: Coroutine[Future[Any], Any, T], final: bool):
+ super().__init__()
+ self.coroutine = coroutine
+ self.final = final
+
+
+def weechat_task_cb(data: str, *args: Any) -> int:
+ task = globals.active_tasks.pop(data)
+ task_runner(task, args)
+ return weechat.WEECHAT_RC_OK
+
+
+def task_runner(task: Task[Any], response: Any):
+ while True:
+ try:
+ future = task.coroutine.send(response)
+ if future.id in globals.active_responses:
+ response = globals.active_responses.pop(future.id)
+ else:
+ if future.id in globals.active_tasks:
+ raise Exception(
+ f"future.id in active_tasks, {future.id}, {globals.active_tasks}"
+ )
+ globals.active_tasks[future.id] = task
+ break
+ except StopIteration as e:
+ if task.id in globals.active_tasks:
+ task = globals.active_tasks.pop(task.id)
+ response = e.value
+ else:
+ if task.id in globals.active_responses:
+ raise Exception( # pylint: disable=raise-missing-from
+ f"task.id in active_responses, {task.id}, {globals.active_responses}"
+ )
+ if not task.final:
+ globals.active_responses[task.id] = e.value
+ break
+
+
+def create_task(
+ coroutine: Coroutine[Future[Any], Any, T], final: bool = False
+) -> Task[T]:
+ task = Task(coroutine, final)
+ task_runner(task, None)
+ return task
+
+
+async def sleep(milliseconds: int):
+ future = FutureTimer()
+ weechat.hook_timer(milliseconds, 0, 1, weechat_task_cb.__name__, future.id)
+ return await future
diff --git a/slack/weechat_http.py b/slack/weechat_http.py
new file mode 100644
index 0000000..df7943d
--- /dev/null
+++ b/slack/weechat_http.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+import os
+import resource
+from io import StringIO
+from typing import Dict
+
+import weechat
+from log import LogLevel, log
+from task import FutureProcess, sleep, weechat_task_cb
+
+
+class HttpError(Exception):
+ def __init__(self, url: str, return_code: int, http_status: int, error: str):
+ super().__init__()
+ self.url = url
+ self.return_code = return_code
+ self.http_status = http_status
+ self.error = error
+
+
+def available_file_descriptors():
+ num_current_file_descriptors = len(os.listdir("/proc/self/fd/"))
+ max_file_descriptors = min(resource.getrlimit(resource.RLIMIT_NOFILE))
+ return max_file_descriptors - num_current_file_descriptors
+
+
+async def hook_process_hashtable(command: str, options: Dict[str, str], timeout: int):
+ future = FutureProcess()
+ log(
+ LogLevel.DEBUG,
+ f"hook_process_hashtable calling ({future.id}): command: {command}",
+ )
+ while available_file_descriptors() < 10:
+ await sleep(10)
+ weechat.hook_process_hashtable(
+ command, options, timeout, weechat_task_cb.__name__, future.id
+ )
+
+ stdout = StringIO()
+ stderr = StringIO()
+ return_code = -1
+
+ while return_code == -1:
+ _, return_code, out, err = await future
+ log(
+ LogLevel.TRACE,
+ f"hook_process_hashtable intermediary response ({future.id}): command: {command}",
+ )
+ stdout.write(out)
+ stderr.write(err)
+
+ out = stdout.getvalue()
+ err = stderr.getvalue()
+ log(
+ LogLevel.DEBUG,
+ f"hook_process_hashtable response ({future.id}): command: {command}, "
+ f"return_code: {return_code}, response length: {len(out)}"
+ + (f", error: {err}" if err else ""),
+ )
+
+ return command, return_code, out, err
+
+
+async def http_request(
+ url: str, options: Dict[str, str], timeout: int, max_retries: int = 5
+) -> str:
+ options["header"] = "1"
+ _, return_code, out, err = await hook_process_hashtable(
+ f"url:{url}", options, timeout
+ )
+
+ if return_code != 0 or err:
+ if max_retries > 0:
+ log(
+ LogLevel.INFO,
+ f"HTTP error, retrying (max {max_retries} times): "
+ f"return_code: {return_code}, error: {err}, url: {url}",
+ )
+ await sleep(1000)
+ return await http_request(url, options, timeout, max_retries - 1)
+ raise HttpError(url, return_code, 0, err)
+
+ headers_end_index = out.index("\r\n\r\n")
+ headers = out[:headers_end_index].split("\r\n")
+ http_status = int(headers[0].split(" ")[1])
+
+ if http_status == 429:
+ for header in headers[1:]:
+ name, value = header.split(":", 1)
+ if name.lower() == "retry-after":
+ retry_after = int(value.strip())
+ log(
+ LogLevel.INFO,
+ f"HTTP ratelimit, retrying in {retry_after} seconds, url: {url}",
+ )
+ await sleep(retry_after * 1000)
+ return await http_request(url, options, timeout)
+
+ body = out[headers_end_index + 4 :]
+
+ if http_status >= 400:
+ raise HttpError(url, return_code, http_status, body)
+
+ return body