slack_client.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876
  1. #!/usr/bin/env python3
  2. ########################################################################################
  3. # slack_client.py
  4. #
  5. # A tiny, standard-library only tool to read credentials from the Slack desktop app and
  6. # make requests to the Slack API on behalf of the user. It has access to the user's
  7. # Google Calendar via the Slack App, and the currently connected WiFi network via
  8. # `networksetup`. Requires `openssl` and `leveldbutil` to run.
  9. #
  10. # Not made to be portable.
  11. # Also a bit shit.
  12. ########################################################################################
  13. from base64 import b64encode
  14. from collections.abc import Generator
  15. from enum import StrEnum
  16. from hashlib import pbkdf2_hmac
  17. from json import loads, dumps
  18. from logging import NOTSET, basicConfig, debug, error
  19. from os import getenv, listdir, makedirs, remove, stat
  20. from os.path import join, isfile, splitext, dirname
  21. from sqlite3 import connect as connect_sqlite
  22. from string import hexdigits
  23. from subprocess import check_output, CalledProcessError, DEVNULL, STDOUT
  24. from sys import exit, argv
  25. from sys import platform
  26. from tempfile import TemporaryDirectory
  27. from time import sleep, time
  28. from typing import cast
  29. from urllib import request, parse
  30. from datetime import datetime, timedelta
  31. from http.client import HTTPResponse
  32. JSONType = str | int | float | None | list["JSONType"] | dict[str, "JSONType"]
  33. JSONDict = dict[str, JSONType]
  34. class WorkTime(StrEnum):
  35. WEEKEND = "WEEKEND"
  36. OFF_HOURS = "OFF_HOURS"
  37. WORK_MORNING = "WORK_MORNING"
  38. WORK_AFTERNOON = "WORK_AFTERNOON"
  39. class EnvConfigKey(StrEnum):
  40. XDG_CACHE_HOME = "XDG_CACHE_HOME"
  41. SLACK_CLIENT_CALENDAR_EVENT_NAME_BREAK = "SLACK_CLIENT_CALENDAR_EVENT_NAME_BREAK"
  42. SLACK_CLIENT_CALENDAR_EVENT_NAME_HOME = "SLACK_CLIENT_CALENDAR_EVENT_NAME_HOME"
  43. SLACK_CLIENT_CALENDAR_EVENT_NAME_OFFICE = "SLACK_CLIENT_CALENDAR_EVENT_NAME_OFFICE"
  44. SLACK_CLIENT_CALENDAR_EVENT_NAME_OUT = "SLACK_CLIENT_CALENDAR_EVENT_NAME_OUT"
  45. SLACK_CLIENT_CALENDAR_EVENT_NAME_SICK = "SLACK_CLIENT_CALENDAR_EVENT_NAME_SICK"
  46. SLACK_CLIENT_NETWORK_NAME_HOME = "SLACK_CLIENT_NETWORK_NAME_HOME"
  47. SLACK_CLIENT_NETWORK_NAME_OFFICE = "SLACK_CLIENT_NETWORK_NAME_OFFICE"
  48. SLACK_CLIENT_NETWORK_NAME_TRANSIT = "SLACK_CLIENT_NETWORK_NAME_TRANSIT"
  49. SLACK_CLIENT_STATUS_HOME = "SLACK_CLIENT_STATUS_HOME"
  50. SLACK_CLIENT_STATUS_HOME_LATER_OFFICE = "SLACK_CLIENT_STATUS_HOME_LATER_OFFICE"
  51. SLACK_CLIENT_STATUS_HOME_MEETING = "SLACK_CLIENT_STATUS_HOME_MEETING"
  52. SLACK_CLIENT_STATUS_OFFICE = "SLACK_CLIENT_STATUS_OFFICE"
  53. SLACK_CLIENT_STATUS_OFFICE_MEETING = "SLACK_CLIENT_STATUS_OFFICE_MEETING"
  54. SLACK_CLIENT_STATUS_OFF_HOURS = "SLACK_CLIENT_STATUS_OFF_HOURS"
  55. SLACK_CLIENT_STATUS_OUT = "SLACK_CLIENT_STATUS_OUT"
  56. SLACK_CLIENT_STATUS_SICK = "SLACK_CLIENT_STATUS_SICK"
  57. SLACK_CLIENT_STATUS_TRANSIT_FROM_OFFICE = "SLACK_CLIENT_STATUS_TRANSIT_FROM_OFFICE"
  58. SLACK_CLIENT_STATUS_TRANSIT_TO_OFFICE = "SLACK_CLIENT_STATUS_TRANSIT_TO_OFFICE"
  59. SLACK_CLIENT_STATUS_VACATION = "SLACK_CLIENT_STATUS_VACATION"
  60. SLACK_CLIENT_STATUS_WEEKEND = "SLACK_CLIENT_STATUS_WEEKEND"
  61. class NetworkLocation(StrEnum):
  62. HOME = "HOME"
  63. OFFICE = "OFFICE"
  64. TRANSIT = "TRANSIT"
  65. UNKNOWN = "UNKNOWN"
  66. class CalendarWorkLocation(StrEnum):
  67. HOME = "HOME"
  68. OFFICE = "OFFICE"
  69. TRANSIT = "TRANSIT"
  70. UNKNOWN = "UNKNOWN"
  71. OUT = "OUT"
  72. SICK = "SICK"
  73. VACATION = "VACATION"
  74. class UserState(StrEnum):
  75. OFF = "OFF"
  76. WEEKEND = "WEEKEND"
  77. OUT = "OUT"
  78. SICK = "SICK"
  79. VACATION = "VACATION"
  80. UNKNOWN = "UNKNOWN"
  81. HOME = "HOME"
  82. HOME_LATER_OFFICE = "HOME_LATER_OFFICE"
  83. HOME_MEETING = "HOME_MEETING"
  84. OFFICE = "OFFICE"
  85. OFFICE_MEETING = "OFFICE_MEETING"
  86. TRANSIT_FROM_OFFICE = "TRANSIT_FROM_OFFICE"
  87. TRANSIT_FROM_OFFICE_MEETING = "TRANSIT_FROM_OFFICE_MEETING"
  88. TRANSIT_TO_OFFICE = "TRANSIT_TO_OFFICE"
  89. TRANSIT_TO_OFFICE_MEETING = "TRANSIT_TO_OFFICE_MEETING"
  90. class Cacheable:
  91. _ensured = False
  92. _cache_path: str = "cache.json"
  93. _cache_expiry = 60 * 60 * 24
  94. def _ensure_and_clear_cache(self):
  95. if self._ensured:
  96. return
  97. makedirs(dirname(self._cache_path), exist_ok=True)
  98. if not isfile(self._cache_path):
  99. with open(self._cache_path, "w") as f:
  100. _ = f.write(dumps({}))
  101. self._ensured = True
  102. if (
  103. datetime.now().timestamp() - stat(self._cache_path).st_birthtime
  104. > self._cache_expiry
  105. ):
  106. debug(f"Reset cache {self._cache_path}")
  107. remove(self._cache_path)
  108. with open(self._cache_path, "w") as f:
  109. _ = f.write(dumps({}))
  110. def _read_cache(self, key: str) -> JSONType:
  111. self._ensure_and_clear_cache()
  112. with open(self._cache_path, "r") as f:
  113. cache: JSONDict = loads(f.read())
  114. if key in cache:
  115. debug(f"{self.__class__.__name__} read cached value `{key}`")
  116. return cache[key]
  117. def _write_cache(self, key: str, value: JSONType):
  118. self._ensure_and_clear_cache()
  119. with open(self._cache_path, "r") as f:
  120. cache: JSONDict = loads(f.read())
  121. cache[key] = value
  122. with open(self._cache_path, "w") as f:
  123. _ = f.write(dumps(cache))
  124. class TimeParser:
  125. def __init__(self, date: datetime) -> None:
  126. self._date = date
  127. def read(
  128. self,
  129. ) -> tuple[WorkTime, int, int, int, int]:
  130. return (
  131. self._get_worktime(),
  132. self._get_workday_end_timestamp(),
  133. self._get_workday_start_timestamp(),
  134. self._get_future_round_timestamp(30),
  135. self._get_future_round_timestamp(60),
  136. )
  137. def _get_worktime(self) -> WorkTime:
  138. weekday = int(self._date.strftime("%w"))
  139. is_weekday = bool(weekday % 6)
  140. hour = int(self._date.strftime("%H"))
  141. if not is_weekday or (weekday == 5 and hour >= 16):
  142. return WorkTime.WEEKEND
  143. if hour < 7 or hour >= 20:
  144. return WorkTime.OFF_HOURS
  145. elif hour <= 12:
  146. return WorkTime.WORK_MORNING
  147. else:
  148. return WorkTime.WORK_AFTERNOON
  149. def _get_workday_end_timestamp(self) -> int:
  150. return round(
  151. datetime(
  152. year=self._date.year,
  153. month=self._date.month,
  154. day=self._date.day,
  155. hour=17,
  156. ).timestamp()
  157. )
  158. def _get_workday_start_timestamp(self) -> int:
  159. future = datetime(
  160. year=self._date.year,
  161. month=self._date.month,
  162. day=self._date.day,
  163. hour=8,
  164. minute=0,
  165. )
  166. if self._date.hour > 17:
  167. future += timedelta(days=1)
  168. while future.weekday() >= 5:
  169. future += timedelta(days=1)
  170. return round(future.timestamp())
  171. def _get_future_round_timestamp(self, minutes: int) -> int:
  172. future = self._date + timedelta(minutes=minutes)
  173. if future.minute % 15 == 0:
  174. future += timedelta(minutes=1)
  175. while future.minute % 15 != 0:
  176. future += timedelta(minutes=1)
  177. return round(future.timestamp())
  178. class EnvConfigParser:
  179. def __init__(self):
  180. self._app_config: dict[EnvConfigKey, str] = {}
  181. for key in EnvConfigKey:
  182. value = getenv(key)
  183. if value is None:
  184. raise ValueError(f"Undefined environment variable: {key}")
  185. self._app_config[key] = value
  186. def read(self):
  187. return self._app_config
  188. def __getitem__(self, name: EnvConfigKey) -> str:
  189. return self._app_config[name]
  190. class NetworkParser:
  191. def __init__(self, env_config: EnvConfigParser):
  192. self._config = env_config.read()
  193. def read(self) -> NetworkLocation:
  194. connected = next(
  195. i.replace(" ", "").replace("\t", "")
  196. for i in (
  197. check_output(["networksetup", "-listpreferredwirelessnetworks", "en0"])
  198. .decode("utf8")
  199. .split("\n")
  200. )[1:]
  201. )
  202. if connected == self._config[EnvConfigKey.SLACK_CLIENT_NETWORK_NAME_HOME]:
  203. return NetworkLocation.HOME
  204. if connected == self._config[EnvConfigKey.SLACK_CLIENT_NETWORK_NAME_OFFICE]:
  205. return NetworkLocation.OFFICE
  206. if connected == self._config[EnvConfigKey.SLACK_CLIENT_NETWORK_NAME_TRANSIT]:
  207. return NetworkLocation.TRANSIT
  208. return NetworkLocation.UNKNOWN
  209. class CookiesParser(Cacheable):
  210. # Read and decrypt Chrome cookies from SQLite database
  211. # Useful resources:
  212. # * https://gist.github.com/creachadair/937179894a24571ce9860e2475a2d2ec
  213. # * https://n8henrie.com/2014/05/decrypt-chrome-cookies-with-python/
  214. def __init__(self, env_config: EnvConfigParser, path: str):
  215. self._path = join(path, "Cookies")
  216. self._env_config = env_config
  217. self._key = None
  218. self._cookies: list[tuple[str, str]] | None = None
  219. self._cache_path = join(
  220. self._env_config[EnvConfigKey.XDG_CACHE_HOME],
  221. "slack_client",
  222. "cookies_cache.json",
  223. )
  224. self._cache_expiry = 60 * 60 * 24 * 365
  225. def read(self) -> Generator[tuple[str, str]]:
  226. if self._key is None:
  227. self._key = self._make_key()
  228. self._cookies = []
  229. for key, value in self._iterate_values():
  230. value = value[3:]
  231. decoded = self._decode(self._key, value)
  232. self._cookies.append((key, decoded))
  233. yield self._cookies[-1]
  234. def _get_password(self, cache: bool = False) -> str:
  235. if cache:
  236. return str(self._read_cache("password"))
  237. try:
  238. security_result = check_output(
  239. ["security", "find-generic-password", "-g", "-s", "Slack Safe Storage"],
  240. stderr=STDOUT,
  241. )
  242. except CalledProcessError:
  243. return self._get_password(True)
  244. password = next(
  245. l
  246. for l in security_result.decode("utf8").split("\n")
  247. if l.startswith("password:")
  248. )
  249. password = password[11:-1]
  250. self._write_cache("password", password)
  251. return password
  252. def _make_key(
  253. self,
  254. ):
  255. return pbkdf2_hmac(
  256. "sha1", self._get_password().encode("utf8"), b"saltysalt", 1003
  257. )[:16]
  258. def _decode(self, key: bytes, value: bytes, cache: bool = True) -> str:
  259. cache_key = b64encode(key + value).decode("ascii")
  260. if (
  261. cache
  262. and (cached_value := self._read_cache(cache_key))
  263. and type(cached_value) == str
  264. ):
  265. return cached_value
  266. with TemporaryDirectory() as directory:
  267. keyfile: str = join(directory, "keyfile")
  268. infile: str = join(directory, "infile")
  269. outfile: str = join(directory, "outfile")
  270. with open(keyfile, "wb") as f:
  271. _ = f.write(key)
  272. with open(infile, "wb") as f:
  273. _ = f.write(value)
  274. _ = check_output(
  275. [
  276. "openssl",
  277. "enc",
  278. "-d",
  279. "-aes-128-cbc",
  280. "-K",
  281. "".join([hex(i)[2:] for i in key]),
  282. "-in",
  283. infile,
  284. "-out",
  285. outfile,
  286. "-iv",
  287. "".join([hex(i)[2:] for i in b" " * 16]),
  288. "-p",
  289. ]
  290. )
  291. with open(outfile, "rb") as f:
  292. result = f.read()
  293. # Rather then spend time understanding the format of the decrypted bytes
  294. # (which does seem to vary), just throw out the first 8 bytes any time
  295. # we don't get a valid ASCII string from decoding.
  296. while True:
  297. try:
  298. result = result.decode("ascii")
  299. break
  300. except UnicodeDecodeError:
  301. result = result[8:]
  302. self._write_cache(cache_key, result)
  303. return result
  304. def _iterate_values(self) -> list[tuple[str, bytes]]:
  305. con = connect_sqlite(self._path)
  306. cur = con.cursor()
  307. res = cur.execute(
  308. 'SELECT name, encrypted_value FROM cookies WHERE host_key=".slack.com"'
  309. )
  310. all: list[tuple[str, bytes]] = res.fetchall()
  311. return list([r for r in all])
  312. class LevelDBParser:
  313. def __init__(self, path: str):
  314. self._path = join(path, "Local Storage/leveldb/")
  315. self._app_configs = None
  316. def read(self):
  317. if self._app_configs is None:
  318. self._app_configs = [c for c in self._load_configs()]
  319. return [c for c in self._app_configs]
  320. def _read_files(self):
  321. for file in listdir(self._path):
  322. file = join(self._path, file)
  323. if not isfile(file) or splitext(file)[-1] != ".ldb":
  324. continue
  325. content = (
  326. check_output(["leveldbutil", "dump", file]).decode("utf8").split("\n")
  327. )
  328. for line in content:
  329. if len(line) == 0:
  330. continue
  331. if line[0] != "'":
  332. debug("Invalid start character detected in " + file)
  333. continue
  334. yield line
  335. def _get_keys_and_values(self):
  336. for line in self._read_files():
  337. key_end = line.find("'", 1)
  338. key = line[1 : key_end - 1]
  339. line = line[key_end:]
  340. line = line[line.find("@") :]
  341. line = line[line.find(":") :]
  342. line = line[line.find("'") :]
  343. if line[0] != "'" or line[-1] != "'":
  344. debug("Invalid start or end character detected")
  345. continue
  346. value = line[1:-1]
  347. yield key, value
  348. def _get_json_values(self):
  349. for key, value in self._get_keys_and_values():
  350. while (
  351. len(value) >= 4
  352. and value[0] == "\\"
  353. and value[1] == "x"
  354. and value[2] in hexdigits
  355. and value[3] in hexdigits
  356. ):
  357. value = value[4:]
  358. try:
  359. value: JSONType = loads(value)
  360. except:
  361. continue
  362. if type(value) != dict:
  363. continue
  364. yield key, value
  365. def _load_configs(self):
  366. def find_config(data: JSONDict) -> JSONDict | None:
  367. if (
  368. "token" in data
  369. and type(data["token"]) == str
  370. and "domain" in data
  371. and "url" in data
  372. and type(data["url"]) == str
  373. and "id" in data
  374. and data["url"].endswith(".slack.com/")
  375. and data["token"].startswith("xoxc")
  376. ):
  377. return data
  378. for _, value in data.items():
  379. if type(value) == dict:
  380. return find_config(value)
  381. for _, value in self._get_json_values():
  382. config = find_config(value)
  383. if config:
  384. yield config
  385. class CalendarParser:
  386. def __init__(self, blocks: list[list[str]], env_config: EnvConfigParser):
  387. self._blocks = blocks
  388. self._env_config = env_config
  389. def read(
  390. self,
  391. ) -> tuple[
  392. CalendarWorkLocation, bool, tuple[list[tuple[list[int], str]], list[list[str]]]
  393. ]:
  394. location: CalendarWorkLocation = CalendarWorkLocation.UNKNOWN
  395. sick = False
  396. out = False
  397. vacation = False
  398. meeting = False
  399. current: list[list[str]] = []
  400. items: list[tuple[list[int], str]] = []
  401. for block in self._blocks:
  402. if block[-1] == "All-day" or block[-1].count("date_long_pretty") >= 2:
  403. if (
  404. "|"
  405. + self._env_config[
  406. EnvConfigKey.SLACK_CLIENT_CALENDAR_EVENT_NAME_OFFICE
  407. ]
  408. in block[0]
  409. ):
  410. location = CalendarWorkLocation.OFFICE
  411. if (
  412. "|"
  413. + self._env_config[
  414. EnvConfigKey.SLACK_CLIENT_CALENDAR_EVENT_NAME_HOME
  415. ]
  416. in block[0]
  417. ):
  418. location = CalendarWorkLocation.HOME
  419. if (
  420. "|"
  421. + self._env_config[
  422. EnvConfigKey.SLACK_CLIENT_CALENDAR_EVENT_NAME_BREAK
  423. ]
  424. in block[0]
  425. ):
  426. vacation = True
  427. if (
  428. "|"
  429. + self._env_config[
  430. EnvConfigKey.SLACK_CLIENT_CALENDAR_EVENT_NAME_SICK
  431. ]
  432. in block[0]
  433. ):
  434. sick = True
  435. if (
  436. "|"
  437. + self._env_config[
  438. EnvConfigKey.SLACK_CLIENT_CALENDAR_EVENT_NAME_OUT
  439. ]
  440. in block[0]
  441. ):
  442. out = True
  443. current.append(block)
  444. elif "!date^" in block[-1]:
  445. chunks = block[-1].split("!date^")
  446. times: list[int] = []
  447. for chunk in chunks:
  448. subchunks = chunk.split("^")
  449. try:
  450. times.append(int(subchunks[0]))
  451. except:
  452. pass
  453. if len(times) != 2:
  454. continue
  455. items.append((times, block[0]))
  456. if times[0] < time() and times[1] > time():
  457. meeting = True
  458. current.append(block)
  459. if out:
  460. location = CalendarWorkLocation.OUT
  461. if sick:
  462. location = CalendarWorkLocation.SICK
  463. if vacation:
  464. location = CalendarWorkLocation.VACATION
  465. return location, meeting, (items, current)
  466. class SlackClient(Cacheable):
  467. def __init__(
  468. self,
  469. env_config: EnvConfigParser,
  470. leveldb_parser: LevelDBParser,
  471. cookies: CookiesParser,
  472. ):
  473. self._env_config = env_config
  474. self._cookies = list(cookies.read())
  475. # TODO: Add support for multiple Slack workspaces by selecting config by ID
  476. self._app_config = leveldb_parser.read().pop()
  477. self._cache_path = join(
  478. self._env_config[EnvConfigKey.XDG_CACHE_HOME],
  479. "slack_client",
  480. "slack_cache.json",
  481. )
  482. self._cache_expiry = 60 * 60
  483. self._token = (
  484. self._app_config["token"]
  485. if "token" in self._app_config and type(self._app_config["token"]) == str
  486. else ""
  487. )
  488. def _call(
  489. self,
  490. method: str,
  491. form_data: None | JSONDict = None,
  492. cache: bool = False,
  493. ) -> JSONDict:
  494. cache_key = dumps([method, form_data])
  495. if cache and (cached := self._read_cache(cache_key)) and type(cached) == dict:
  496. return cached
  497. req = request.Request(
  498. f"{self._app_config['url']}/api/{method}",
  499. headers={
  500. "Authorization": "Bearer " + self._token,
  501. "Cookie": ";".join([f"{k}={v}" for k, v in self._cookies]),
  502. },
  503. data=parse.urlencode(
  504. (form_data if form_data else {})
  505. | {"_x_mode": "online", "_x_sonic": "true", "_x_app_name": "client"}
  506. ).encode(),
  507. )
  508. response = cast(HTTPResponse, request.urlopen(req))
  509. result: JSONDict = loads(response.read())
  510. if result["ok"]:
  511. if cache:
  512. self._write_cache(cache_key, result)
  513. return result
  514. raise ValueError("Bad result from Slack API call:", dumps(result))
  515. def update_status(self, text: str, emoji: str, expiration: int):
  516. return self._call(
  517. "users.profile.set",
  518. {
  519. "profile": {
  520. "status_emoji": emoji,
  521. "status_expiration": expiration,
  522. "status_text": text,
  523. "status_text_canonical": "",
  524. "ooo_message": "",
  525. },
  526. "_x_reason": "CustomStatusModal:handle_save",
  527. },
  528. )
  529. def update_presence(self, presence: bool):
  530. return self._call("profile.set", {"presence": "active" if presence else "away"})
  531. def get_calendar(self):
  532. users = self._call("users.list", cache=True)
  533. calendar_user = next(
  534. i
  535. for i in cast(list[JSONType], users["members"])
  536. if type(i) == dict
  537. and i["is_bot"]
  538. and "real_name" in i
  539. and i["real_name"] == "Google Calendar"
  540. )
  541. bot_id: str = cast(str, cast(JSONDict, calendar_user["profile"])["bot_id"])
  542. bot_convo = self._call(
  543. "conversations.open", {"users": calendar_user["id"]}, cache=True
  544. )
  545. convo_id: str = cast(str, cast(JSONDict, bot_convo["channel"])["id"])
  546. convo_info = self._call(
  547. "conversations.info", {"channel": convo_id, "return_app_home": True}, False
  548. )
  549. # The content of this view might not be properly updated - try some tricks to
  550. # force it to refresh!
  551. team_id: str = cast(
  552. str,
  553. cast(
  554. JSONDict,
  555. convo_info["home_view"],
  556. )["team_id"],
  557. )
  558. _ = self._call(
  559. "apps.home.dispatchOpenEvent",
  560. {
  561. "id": convo_id,
  562. "type": "home",
  563. "service_team_id": team_id,
  564. },
  565. False,
  566. )
  567. _ = self._call("apps.profile.get", {"bot": bot_id}, False)
  568. app_view = self._call(
  569. "views.get",
  570. {
  571. "view_id": cast(JSONDict, convo_info["home_view"])["id"],
  572. "_x_reason": "fetchView",
  573. },
  574. False,
  575. )
  576. # Click the "Tomorrow" button, then the "Today" button
  577. view: JSONDict = cast(JSONDict, app_view["view"])
  578. view_id: str = cast(str, view["id"])
  579. blocks: list[JSONDict] = cast(list[JSONDict], view["blocks"])
  580. hash: str = cast(str, view["hash"])
  581. def click_block_action(value: str):
  582. for block in blocks:
  583. if block["type"] != "actions":
  584. continue
  585. for button in cast(list[JSONDict], block["elements"]):
  586. if "value" not in button or button["value"] != value:
  587. continue
  588. button["block_id"] = block["block_id"]
  589. _ = self._call(
  590. "blocks.actions",
  591. {
  592. "service_id": bot_id,
  593. "service_team_id": team_id,
  594. "actions": dumps([button]),
  595. "container": dumps({"type": "view", "view_id": view_id}),
  596. "client_token": "web-" + hash.split(".")[0],
  597. },
  598. False,
  599. )
  600. break
  601. for button in ["AGENDA_TOMORROW", "AGENDA_TODAY"]:
  602. click_block_action(button)
  603. # Gotta sleep, to ensure that the app view has time to update
  604. sleep(5)
  605. app_view = self._call(
  606. "views.get",
  607. {
  608. "view_id": cast(JSONDict, convo_info["home_view"])["id"],
  609. "_x_reason": "fetchView",
  610. },
  611. False,
  612. )
  613. texts = [
  614. cast(str, cast(JSONDict, b["text"])["text"]).split("\n")
  615. for b in cast(list[JSONDict], cast(JSONDict, app_view["view"])["blocks"])
  616. if "text" in b
  617. ]
  618. texts = [t for t in texts if len(t) and any(len(b) for b in t) > 0]
  619. texts = [loads(t) for t in set(dumps(t) for t in texts)]
  620. return texts
  621. def userstate_parse(
  622. worktime: WorkTime,
  623. network_location: NetworkLocation,
  624. work_location: CalendarWorkLocation,
  625. meeting: bool,
  626. ) -> UserState:
  627. if network_location == NetworkLocation.OFFICE:
  628. if meeting and (worktime == "afternoon" or worktime == "evening"):
  629. return UserState.OFFICE_MEETING
  630. return UserState.OFFICE
  631. elif work_location == CalendarWorkLocation.VACATION:
  632. return UserState.VACATION
  633. elif work_location == CalendarWorkLocation.SICK:
  634. return UserState.SICK
  635. elif work_location == CalendarWorkLocation.OUT:
  636. return UserState.OUT
  637. elif worktime == WorkTime.WEEKEND:
  638. return UserState.WEEKEND
  639. elif worktime == WorkTime.OFF_HOURS:
  640. return UserState.OFF
  641. elif work_location == CalendarWorkLocation.OFFICE:
  642. if network_location == NetworkLocation.TRANSIT:
  643. if worktime == WorkTime.WORK_MORNING:
  644. if meeting:
  645. return UserState.TRANSIT_TO_OFFICE_MEETING
  646. return UserState.TRANSIT_TO_OFFICE
  647. elif worktime == WorkTime.WORK_AFTERNOON:
  648. if meeting:
  649. return UserState.TRANSIT_FROM_OFFICE_MEETING
  650. return UserState.TRANSIT_FROM_OFFICE
  651. elif network_location == NetworkLocation.HOME:
  652. if worktime == WorkTime.WORK_MORNING:
  653. return UserState.HOME_LATER_OFFICE
  654. elif worktime == WorkTime.WORK_AFTERNOON:
  655. return userstate_parse(
  656. worktime, network_location, CalendarWorkLocation.HOME, meeting
  657. )
  658. else:
  659. return userstate_parse(
  660. worktime, NetworkLocation.OFFICE, work_location, meeting
  661. )
  662. elif work_location == CalendarWorkLocation.HOME:
  663. if meeting:
  664. return UserState.HOME_MEETING
  665. return UserState.HOME
  666. elif work_location == CalendarWorkLocation.UNKNOWN:
  667. return userstate_parse(
  668. worktime, network_location, CalendarWorkLocation.HOME, meeting
  669. )
  670. return UserState.UNKNOWN
  671. def envvar_to_slack_status(status: str) -> tuple[str, str]:
  672. if "|" not in status:
  673. raise ValueError(status)
  674. emoji, text = tuple(status.split("|"))[:2]
  675. return (":" + emoji + ":") if len(emoji) > 0 else emoji, text
  676. def main():
  677. base_path = join(getenv("HOME", "~"), "Library/Application Support/Slack")
  678. env_config_parser = EnvConfigParser()
  679. leveldb_parser = LevelDBParser(base_path)
  680. cookies_parser = CookiesParser(env_config_parser, base_path)
  681. network_parser = NetworkParser(env_config_parser)
  682. time_parser = TimeParser(datetime.now())
  683. client = SlackClient(env_config_parser, leveldb_parser, cookies_parser)
  684. calendar_parser = CalendarParser(client.get_calendar(), env_config_parser)
  685. worktime, workday_end_expiry, workday_start_expiry, halfhour_expiry, hour_expiry = (
  686. time_parser.read()
  687. )
  688. work_location, meeting, (calendar_items, _) = calendar_parser.read()
  689. for calendar_item in calendar_items:
  690. debug(f"Calendar item: {calendar_item}")
  691. network_location = network_parser.read()
  692. userstate = userstate_parse(worktime, network_location, work_location, meeting)
  693. debug(
  694. f"State information: \
  695. {userstate=} \
  696. {worktime=}, \
  697. {network_location=}, \
  698. {work_location=}, \
  699. {meeting=}"
  700. )
  701. status_emoji: None | str = None
  702. status_text: None | str = None
  703. status_expiration: None | int = None
  704. match userstate:
  705. case UserState.OFF:
  706. status_emoji, status_text = envvar_to_slack_status(
  707. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_OFF_HOURS]
  708. )
  709. status_expiration = workday_start_expiry
  710. case UserState.WEEKEND:
  711. status_emoji, status_text = envvar_to_slack_status(
  712. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_WEEKEND]
  713. )
  714. status_expiration = workday_start_expiry
  715. case UserState.OUT:
  716. status_emoji, status_text = envvar_to_slack_status(
  717. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_OUT]
  718. )
  719. case UserState.SICK:
  720. status_emoji, status_text = envvar_to_slack_status(
  721. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_SICK]
  722. )
  723. status_expiration = workday_end_expiry
  724. case UserState.VACATION:
  725. status_emoji, status_text = envvar_to_slack_status(
  726. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_VACATION]
  727. )
  728. case UserState.HOME:
  729. status_emoji, status_text = envvar_to_slack_status(
  730. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_HOME]
  731. )
  732. status_expiration = workday_end_expiry
  733. case UserState.HOME_LATER_OFFICE:
  734. status_emoji, status_text = envvar_to_slack_status(
  735. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_HOME_LATER_OFFICE]
  736. )
  737. status_expiration = hour_expiry
  738. case UserState.HOME_MEETING:
  739. status_emoji, status_text = envvar_to_slack_status(
  740. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_HOME_MEETING]
  741. )
  742. status_expiration = halfhour_expiry
  743. case UserState.OFFICE:
  744. status_emoji, status_text = envvar_to_slack_status(
  745. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_OFFICE]
  746. )
  747. status_expiration = workday_end_expiry
  748. case UserState.OFFICE_MEETING:
  749. status_emoji, status_text = envvar_to_slack_status(
  750. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_OFFICE_MEETING]
  751. )
  752. status_expiration = halfhour_expiry
  753. case UserState.TRANSIT_FROM_OFFICE:
  754. status_emoji, status_text = envvar_to_slack_status(
  755. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_TRANSIT_FROM_OFFICE]
  756. )
  757. status_expiration = halfhour_expiry
  758. case UserState.TRANSIT_FROM_OFFICE_MEETING:
  759. status_emoji, status_text = envvar_to_slack_status(
  760. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_TRANSIT_FROM_OFFICE]
  761. )
  762. status_expiration = halfhour_expiry
  763. case UserState.TRANSIT_TO_OFFICE:
  764. status_emoji, status_text = envvar_to_slack_status(
  765. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_TRANSIT_TO_OFFICE]
  766. )
  767. status_expiration = halfhour_expiry
  768. case UserState.TRANSIT_TO_OFFICE_MEETING:
  769. status_emoji, status_text = envvar_to_slack_status(
  770. env_config_parser[EnvConfigKey.SLACK_CLIENT_STATUS_TRANSIT_TO_OFFICE]
  771. )
  772. status_expiration = halfhour_expiry
  773. case UserState.UNKNOWN:
  774. pass
  775. _ = client.update_status(
  776. status_text if status_text else "",
  777. status_emoji if status_emoji else "",
  778. status_expiration if status_expiration else 0,
  779. )
  780. debug(f"Updated status: {status_text=} {status_emoji=} {status_expiration=}")
  781. if __name__ == "__main__":
  782. basicConfig(level=NOTSET)
  783. debug(f"Called at {datetime.now().ctime()} with parameters {argv}")
  784. if platform != "darwin":
  785. error(f"This client only runs on macOS")
  786. exit(1)
  787. for program in ["networksetup", "leveldbutil", "openssl"]:
  788. try:
  789. _ = check_output([program, "--version"], stderr=DEVNULL)
  790. except CalledProcessError:
  791. pass
  792. except:
  793. error(f"Aborted, as `{program}` doesn't appear to be callable")
  794. exit(1)
  795. main()