diff --git a/.gitignore b/.gitignore index 5f26f24..5b30896 100644 --- a/.gitignore +++ b/.gitignore @@ -104,4 +104,11 @@ venv.bak/ .mypy_cache/ # VSCode stuff -.vscode \ No newline at end of file +.vscode + +# Sclack user config and logs (contains tokens - never commit!) +.sclack +.sclack_logs/ +*.log +~/.sclack +~/.sclack_logs/ \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..af8d7df --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,70 @@ +# Installing sclack System-Wide + +## Option 1: Using pipx (Recommended for Arch Linux / PEP 668) +`pipx` is perfect for CLI applications and manages its own virtual environment: + +```bash +# Install pipx if you don't have it (Arch Linux) +sudo pacman -S python-pipx + +# Install sclack using pipx +cd /home/deadc0de/hacking/sclack +pipx install -e . + +# Or install normally (not editable) +pipx install . +``` + +After installation, `sclack` will be available globally: +```bash +which sclack +sclack # Run it! +``` + +To update: +```bash +pipx upgrade sclack +``` + +To uninstall: +```bash +pipx uninstall sclack +``` + +## Option 2: User Installation (Other Linux distros) +For distros without PEP 668 protection: + +```bash +# Make sure you're NOT in the venv +deactivate # if you're in a venv + +# Install in user directory +pip3 install --user -e . + +# Make sure ~/.local/bin is in your PATH +export PATH="$HOME/.local/bin:$PATH" +# Add to ~/.zshrc or ~/.bashrc to make it permanent +``` + +## Option 3: System-Wide Installation (Not recommended on Arch) +```bash +# Make sure you're NOT in the venv +deactivate # if you're in a venv + +# Install system-wide (requires --break-system-packages on Arch) +sudo pip3 install --break-system-packages -e . +``` + +## Verify Installation +After installation, verify it works: +```bash +which sclack +sclack # Run it! +``` + +## Development Mode +If you want to keep developing and have changes reflect immediately, use `-e` (editable) flag: +```bash +pipx install -e . # Changes to code will be reflected immediately +``` + diff --git a/Pipfile b/Pipfile index 3069121..2dbc096 100644 --- a/Pipfile +++ b/Pipfile @@ -5,7 +5,7 @@ name = "pypi" [packages] asyncio = "*" -urwid = "==2.0.1" +urwid = ">=3.0.3" pyperclip = "==1.6.2" requests = "*" slackclient = "==1.2.1" diff --git a/requirements.txt b/requirements.txt index ac341a7..4275a6a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ asyncio -urwid==2.0.1 +urwid>=3.0.3 pyperclip==1.6.2 requests slackclient==1.2.1 diff --git a/sclack/app.py b/sclack/app.py index b1f6dc0..d99e027 100755 --- a/sclack/app.py +++ b/sclack/app.py @@ -3,6 +3,7 @@ import concurrent.futures import functools import json +import logging import os import requests import sys @@ -20,13 +21,17 @@ from sclack.image import Image from sclack.loading import LoadingChatBox, LoadingSideBar from sclack.quick_switcher import QuickSwitcher -from sclack.store import Store +from sclack.store import Store, logger from sclack.themes import themes from sclack.widgets.set_snooze import SetSnoozeWidget from sclack.utils.channel import is_dm, is_group, is_channel -loop = asyncio.get_event_loop() +try: + loop = asyncio.get_running_loop() +except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) SCLACK_SUBTYPE = 'sclack_message' MARK_READ_ALARM_PERIOD = 3 @@ -132,23 +137,29 @@ def chatbox(self): def chatbox(self, chatbox): self.columns.contents[1][0].original_widget = chatbox - @asyncio.coroutine - def animate_loading(self): + async def animate_loading(self): def update(*args): if self._loading: self.chatbox.circular_loading.next_frame() self.urwid_loop.set_alarm_in(0.2, update) update() - @asyncio.coroutine - def component_did_mount(self): + async def component_did_mount(self): with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor: - yield from self.mount_sidebar(executor) - yield from self.mount_chatbox(executor, self.store.state.channels[0]['id']) + await self.mount_sidebar(executor) + # Only mount chatbox if we have channels, DMs, or group DMs + if len(self.store.state.channels) > 0: + await self.mount_chatbox(executor, self.store.state.channels[0]['id']) + elif len(self.store.state.dms) > 0: + await self.mount_chatbox(executor, self.store.state.dms[0]['id']) + elif len(self.store.state.mpims) > 0: + await self.mount_chatbox(executor, self.store.state.mpims[0]['id']) + else: + # No channels, DMs, or group DMs available + self.chatbox = LoadingChatBox('No channels, direct messages, or group messages available.') - @asyncio.coroutine - def mount_sidebar(self, executor): - yield from asyncio.gather( + async def mount_sidebar(self, executor): + await asyncio.gather( loop.run_in_executor(executor, self.store.load_auth), loop.run_in_executor(executor, self.store.load_channels), loop.run_in_executor(executor, self.store.load_stars), @@ -170,16 +181,17 @@ def mount_sidebar(self, executor): for dm in self.store.state.stars: if is_dm(dm['channel']): detail = self.store.get_channel_info(dm['channel']) - user = self.store.find_user_by_id(detail['user']) - - if user: - stars_user_id.append(user['id']) - star_user_tmp.append(Dm( - dm['channel'], - name=self.store.get_user_display_name(user), - user=user['id'], - you=False - )) + if detail and 'user' in detail: + user = self.store.find_user_by_id(detail['user']) + + if user: + stars_user_id.append(user['id']) + star_user_tmp.append(Dm( + dm['channel'], + name=self.store.get_user_display_name(user), + user=user['id'], + you=False + )) elif is_channel(dm['channel']) or is_group(dm['channel']): channel = self.store.get_channel_info(dm['channel']) # Group chat (is_mpim) is not supported, prefer to https://github.com/haskellcamargo/sclack/issues/67 @@ -187,8 +199,8 @@ def mount_sidebar(self, executor): stars_channel_id.append(channel['id']) stars.append(Channel( id=channel['id'], - name=channel['name'], - is_private=channel.get('is_private', True) + name=channel.get('name', 'Unknown'), + is_private=channel.get('is_private', False) )) stars.extend(star_user_tmp) @@ -196,10 +208,13 @@ def mount_sidebar(self, executor): for channel in self.store.state.channels: if channel['id'] in stars_channel_id: continue + # Skip channels without a name (shouldn't happen, but be safe) + if 'name' not in channel: + continue channels.append(Channel( id=channel['id'], name=channel['name'], - is_private=channel['is_private'] + is_private=channel.get('is_private', False) )) # Prepare list of DM @@ -215,15 +230,29 @@ def mount_sidebar(self, executor): user=dm['user'], you=user['id'] == self.store.state.auth['user_id'] )) + + # Prepare list of group DMs (mpim) + from sclack.utils.channel import get_group_name + for mpim in self.store.state.mpims: + # Format group DM name using utility function + group_name = get_group_name(mpim.get('name', mpim.get('id', 'Unknown Group'))) + # Create a Dm widget for group DMs (they work similarly to DMs) + dms.append(Dm( + mpim['id'], + name=group_name, + user=None, # Group DMs don't have a single user + you=False + )) self.sidebar = SideBar(profile, channels, dms, stars=stars, title=self.store.state.auth['team']) urwid.connect_signal(self.sidebar, 'go_to_channel', self.go_to_channel) - loop.create_task(self.get_channels_info(executor, self.sidebar.get_all_channels())) - loop.create_task(self.get_presences(executor, self.sidebar.get_all_dms())) - loop.create_task(self.get_dms_unread(executor, self.sidebar.get_all_dms())) + await asyncio.gather( + self.get_channels_info(executor, self.sidebar.get_all_channels()), + self.get_presences(executor, self.sidebar.get_all_dms()), + self.get_dms_unread(executor, self.sidebar.get_all_dms()) + ) - @asyncio.coroutine - def get_presences(self, executor, dm_widgets): + async def get_presences(self, executor, dm_widgets): """ Compute and return presence because updating UI from another thread is unsafe :param executor: @@ -231,20 +260,22 @@ def get_presences(self, executor, dm_widgets): :return: """ def get_presence(dm_widget): + # Skip presence check for group DMs (they don't have a single user) + if dm_widget.user is None: + return [dm_widget, {'ok': False}] presence = self.store.get_presence(dm_widget.user) return [dm_widget, presence] - presences = yield from asyncio.gather(*[ + presences = await asyncio.gather(*[ loop.run_in_executor(executor, get_presence, dm_widget) for dm_widget in dm_widgets ]) for presence in presences: [widget, response] = presence - if response['ok']: + if response.get('ok', False): widget.set_presence(response['presence']) - @asyncio.coroutine - def get_dms_unread(self, executor, dm_widgets): + async def get_dms_unread(self, executor, dm_widgets): """ Compute and return unread_count_display because updating UI from another thread is unsafe :param executor: @@ -255,7 +286,7 @@ def get_presence(dm_widget): profile_response = self.store.get_channel_info(dm_widget.id) return [dm_widget, profile_response] - responses = yield from asyncio.gather(*[ + responses = await asyncio.gather(*[ loop.run_in_executor(executor, get_presence, dm_widget) for dm_widget in dm_widgets ]) @@ -263,24 +294,24 @@ def get_presence(dm_widget): for profile_response in responses: [widget, response] = profile_response if response is not None: - widget.set_unread(response['unread_count_display']) + # Use .get() with default 0 in case unread_count_display is missing (e.g., for group DMs) + widget.set_unread(response.get('unread_count_display', 0)) - @asyncio.coroutine - def get_channels_info(self, executor, channels): + async def get_channels_info(self, executor, channels): def get_info(channel): info = self.store.get_channel_info(channel.id) return [channel, info] - channels_info = yield from asyncio.gather(*[ + channels_info = await asyncio.gather(*[ loop.run_in_executor(executor, get_info, channel) for channel in channels ]) for channel_info in channels_info: [widget, response] = channel_info - widget.set_unread(response.get('unread_count_display', 0)) + if response is not None: + widget.set_unread(response.get('unread_count_display', 0)) - @asyncio.coroutine - def update_chat(self, event): + async def update_chat(self, event): """ Update channel/DM message count badge :param event: @@ -288,18 +319,31 @@ def update_chat(self, event): """ self.sidebar.update_items(event) - @asyncio.coroutine - def mount_chatbox(self, executor, channel): - yield from asyncio.gather( + async def mount_chatbox(self, executor, channel): + await asyncio.gather( loop.run_in_executor(executor, self.store.load_channel, channel), loop.run_in_executor(executor, self.store.load_messages, channel) ) + # Ensure we have valid channel and messages data + if not self.store.state.channel or 'id' not in self.store.state.channel: + self.chatbox = LoadingChatBox('Failed to load channel information.') + self._loading = False + return + messages = self.render_messages(self.store.state.messages) header = self.render_chatbox_header() self._loading = False self.sidebar.select_channel(channel) + + # Debug: log message rendering + logger.info(f"Rendered {len(messages)} message widgets for display") + if len(messages) == 0 and len(self.store.state.messages) > 0: + logger.warning("Messages loaded but none rendered - check render_messages logic") + elif len(messages) == 0 and len(self.store.state.messages) == 0: + logger.info("No messages in this channel/conversation") + self.message_box = MessageBox( - user=self.store.state.auth['user'], + user=self.store.state.auth.get('user', 'Unknown'), is_read_only=self.store.state.channel.get('is_read_only', False) ) self.chatbox = ChatBox(messages, header, self.message_box, self.urwid_loop) @@ -362,12 +406,27 @@ def go_to_profile(self, user_id): self.columns.contents.append((profile, ('given', 35, False))) def render_chatbox_header(self): + # Check if channel data is available + if not self.store.state.channel or 'id' not in self.store.state.channel: + # Return a default header if channel data is not available + return ChannelHeader( + name='Unknown', + topic='', + is_starred=False + ) if self.store.state.channel['id'][0] == 'D': - user = self.store.find_user_by_id(self.store.state.channel['user']) + user = self.store.find_user_by_id(self.store.state.channel.get('user')) + if not user: + return ChannelHeader( + name='Unknown User', + topic='', + is_starred=self.store.state.channel.get('is_starred', False), + is_dm_workaround_please_remove_me=True + ) header = ChannelHeader( - name=user.get('display_name') or user.get('real_name') or user['name'], - topic=user['profile']['status_text'], + name=user.get('display_name') or user.get('real_name') or user.get('name', 'Unknown'), + topic=user.get('profile', {}).get('status_text', ''), is_starred=self.store.state.channel.get('is_starred', False), is_dm_workaround_please_remove_me=True ) @@ -377,9 +436,9 @@ def render_chatbox_header(self): if self.store.state.members['response_metadata'].get('next_cursor', None): are_more_members = True header = ChannelHeader( - name=self.store.state.channel['name'], - topic=self.store.state.channel['topic']['value'], - num_members=len(self.store.state.members['members']), + name=self.store.state.channel.get('name', 'Unknown'), + topic=self.store.state.channel.get('topic', {}).get('value', ''), + num_members=len(self.store.state.members.get('members', [])), more_members=are_more_members, pin_count=self.store.state.pin_count, is_private=self.store.state.channel.get('is_group', False), @@ -546,9 +605,21 @@ def lazy_load_images(self, files, widget): def render_messages(self, messages, channel_id=None): _messages = [] previous_date = self.store.state.last_date - last_read_datetime = datetime.fromtimestamp(float(self.store.state.channel.get('last_read', '0'))) + # Handle case where channel might not be loaded yet + last_read_ts = '0' + if self.store.state.channel and 'last_read' in self.store.state.channel: + last_read_ts = self.store.state.channel.get('last_read', '0') + last_read_datetime = datetime.fromtimestamp(float(last_read_ts)) today = datetime.today().date() + + logger.debug(f"render_messages called with {len(messages)} messages") + for message in messages: + # Skip if message doesn't have required fields + if 'ts' not in message: + logger.warning(f"Message missing 'ts' field: {message}") + continue + message_datetime = datetime.fromtimestamp(float(message['ts'])) message_date = message_datetime.date() date_text = None @@ -571,11 +642,14 @@ def render_messages(self, messages, channel_id=None): elif date_text is not None: _messages.append(TextDivider(('history_date', date_text), 'center')) - message = self.render_message(message, channel_id) - - if message is not None: - _messages.append(message) + rendered_message = self.render_message(message, channel_id) + if rendered_message is not None: + _messages.append(rendered_message) + else: + logger.warning(f"render_message returned None for message with ts={message.get('ts', 'N/A')}") + + logger.debug(f"render_messages returning {len(_messages)} widgets") return _messages def handle_mark_read(self, data): @@ -602,8 +676,7 @@ def scroll_messages(self, *args): self.mark_read_slack(index) ) - @asyncio.coroutine - def mark_read_slack(self, index): + async def mark_read_slack(self, index): if not self.is_chatbox_rendered: return @@ -623,10 +696,9 @@ def mark_read_slack(self, index): if message.channel_id: self.store.mark_read(message.channel_id, message.ts) - @asyncio.coroutine - def _go_to_channel(self, channel_id): + async def _go_to_channel(self, channel_id): with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor: - yield from asyncio.gather( + await asyncio.gather( loop.run_in_executor(executor, self.store.load_channel, channel_id), loop.run_in_executor(executor, self.store.load_messages, channel_id) ) @@ -675,12 +747,10 @@ def handle_close_set_snooze(self): self.urwid_loop.widget = self._body self.set_snooze_widget = None - @asyncio.coroutine - def dispatch_snooze_time(self, snoozed_time): + async def dispatch_snooze_time(self, snoozed_time): self.store.set_snooze(snoozed_time) - @asyncio.coroutine - def load_picture_async(self, url, width, message_widget, auth=True): + async def load_picture_async(self, url, width, message_widget, auth=True): width = min(width, 800) bytes_in_cache = self.store.cache.picture.get(url) if bytes_in_cache: @@ -690,7 +760,7 @@ def load_picture_async(self, url, width, message_widget, auth=True): headers = {} if auth: headers = {'Authorization': 'Bearer {}'.format(self.store.slack_token)} - bytes = yield from loop.run_in_executor( + bytes = await loop.run_in_executor( executor, functools.partial(requests.get, url, headers=headers) ) @@ -700,14 +770,13 @@ def load_picture_async(self, url, width, message_widget, auth=True): picture = Image(file.name, width=(width / 10)) message_widget.file = picture - @asyncio.coroutine - def load_profile_avatar(self, url, profile): + async def load_profile_avatar(self, url, profile): bytes_in_cache = self.store.cache.avatar.get(url) if bytes_in_cache: profile.avatar = bytes_in_cache return with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor: - bytes = yield from loop.run_in_executor(executor, requests.get, url) + bytes = await loop.run_in_executor(executor, requests.get, url) file = tempfile.NamedTemporaryFile(delete=False) file.write(bytes.content) file.close() @@ -715,9 +784,34 @@ def load_profile_avatar(self, url, profile): self.store.cache.avatar[url] = avatar profile.avatar = avatar - @asyncio.coroutine - def start_real_time(self): - self.store.slack.rtm_connect(auto_reconnect=True) + async def start_real_time(self): + import sys + import io + + # Suppress stderr temporarily to prevent traceback from appearing on screen + old_stderr = sys.stderr + sys.stderr = io.StringIO() + + try: + self.store.slack.rtm_connect(auto_reconnect=True) + # Restore stderr after successful connection + sys.stderr = old_stderr + except Exception as e: + # Restore stderr before logging + sys.stderr = old_stderr + # RTM API might be deprecated or unavailable, continue without real-time updates + # Catch SlackLoginError and other RTM connection errors + error_type = type(e).__name__ + error_msg = str(e) + logger.warning(f"Could not connect to RTM API ({error_type}): {error_msg}") + logger.warning("Real-time updates will not be available, but the app will continue to work.") + # Don't raise the exception - just return silently + return + + # Check if connection actually succeeded + if not hasattr(self.store.slack, 'server') or not self.store.slack.server.connected: + logger.warning("RTM connection failed silently - server not connected") + return def stop_typing(*args): # Prevent error while switching workspace @@ -726,83 +820,88 @@ def stop_typing(*args): alarm = None - while self.store.slack.server.connected is True: - events = self.store.slack.rtm_read() - - for event in events: - if event.get('type') == 'hello': - pass - elif event.get('type') in ('channel_marked', 'group_marked', 'im_marked'): - unread = event.get('unread_count_display', 0) - - if event.get('type') == 'channel_marked': - targets = self.sidebar.get_all_channels() - elif event.get('type') == 'group_marked': - targets = self.sidebar.get_all_groups() - else: - targets = self.sidebar.get_all_dms() - - for target in targets: - if target.id == event['channel']: - target.set_unread(unread) + try: + while self.store.slack.server.connected is True: + events = self.store.slack.rtm_read() - elif event['type'] == 'message': - loop.create_task( - self.update_chat(event) - ) + for event in events: + if event.get('type') == 'hello': + pass + elif event.get('type') in ('channel_marked', 'group_marked', 'im_marked'): + unread = event.get('unread_count_display', 0) - if event.get('channel') == self.store.state.channel['id']: + if event.get('type') == 'channel_marked': + targets = self.sidebar.get_all_channels() + elif event.get('type') == 'group_marked': + targets = self.sidebar.get_all_groups() + else: + targets = self.sidebar.get_all_dms() + + for target in targets: + if target.id == event['channel']: + target.set_unread(unread) + + elif event['type'] == 'message': + loop.create_task( + self.update_chat(event) + ) + + if event.get('channel') == self.store.state.channel['id']: + if not self.is_chatbox_rendered: + return + + if event.get('subtype') == 'message_deleted': + for widget in self.chatbox.body.body: + if hasattr(widget, 'ts') and getattr(widget, 'ts') == event['deleted_ts']: + self.chatbox.body.body.remove(widget) + break + elif event.get('subtype') == 'message_changed': + for index, widget in enumerate(self.chatbox.body.body): + if hasattr(widget, 'ts') and getattr(widget, 'ts') == event['message']['ts']: + self.chatbox.body.body[index] = self.render_message(event['message']) + break + else: + self.chatbox.body.body.extend(self.render_messages([event])) + self.chatbox.body.scroll_to_bottom() + else: + pass + elif event['type'] == 'user_typing': if not self.is_chatbox_rendered: return - if event.get('subtype') == 'message_deleted': - for widget in self.chatbox.body.body: - if hasattr(widget, 'ts') and getattr(widget, 'ts') == event['deleted_ts']: - self.chatbox.body.body.remove(widget) - break - elif event.get('subtype') == 'message_changed': - for index, widget in enumerate(self.chatbox.body.body): - if hasattr(widget, 'ts') and getattr(widget, 'ts') == event['message']['ts']: - self.chatbox.body.body[index] = self.render_message(event['message']) - break + if event.get('channel') == self.store.state.channel['id']: + user = self.store.find_user_by_id(event['user']) + name = user.get('display_name') or user.get('real_name') or user['name'] + if alarm is not None: + self.urwid_loop.remove_alarm(alarm) + self.chatbox.message_box.typing = name + self.urwid_loop.set_alarm_in(3, stop_typing) else: - self.chatbox.body.body.extend(self.render_messages([event])) - self.chatbox.body.scroll_to_bottom() - else: - pass - elif event['type'] == 'user_typing': - if not self.is_chatbox_rendered: - return - - if event.get('channel') == self.store.state.channel['id']: - user = self.store.find_user_by_id(event['user']) - name = user.get('display_name') or user.get('real_name') or user['name'] - if alarm is not None: - self.urwid_loop.remove_alarm(alarm) - self.chatbox.message_box.typing = name - self.urwid_loop.set_alarm_in(3, stop_typing) + pass + # print(json.dumps(event, indent=2)) + elif event.get('type') == 'dnd_updated' and 'dnd_status' in event: + self.store.is_snoozed = event['dnd_status']['snooze_enabled'] + self.sidebar.profile.set_snooze(self.store.is_snoozed) + elif event.get('ok', False): + if not self.is_chatbox_rendered: + return + + # Message was sent, Slack confirmed it. + self.chatbox.body.body.extend(self.render_messages([{ + 'text': event['text'], + 'ts': event['ts'], + 'user': self.store.state.auth['user_id'] + }])) + self.chatbox.body.scroll_to_bottom() + self.handle_mark_read(-1) else: pass # print(json.dumps(event, indent=2)) - elif event.get('type') == 'dnd_updated' and 'dnd_status' in event: - self.store.is_snoozed = event['dnd_status']['snooze_enabled'] - self.sidebar.profile.set_snooze(self.store.is_snoozed) - elif event.get('ok', False): - if not self.is_chatbox_rendered: - return - - # Message was sent, Slack confirmed it. - self.chatbox.body.body.extend(self.render_messages([{ - 'text': event['text'], - 'ts': event['ts'], - 'user': self.store.state.auth['user_id'] - }])) - self.chatbox.body.scroll_to_bottom() - self.handle_mark_read(-1) - else: - pass - # print(json.dumps(event, indent=2)) - yield from asyncio.sleep(0.5) + await asyncio.sleep(0.5) + except Exception as e: + # Handle errors in RTM loop gracefully + logger.warning(f"RTM loop error: {e}") + logger.warning("Real-time updates stopped, but the app will continue to work.") def set_insert_mode(self): self.columns.focus_position = 1 @@ -959,6 +1058,16 @@ def ask_for_token(json_config): json_config.update(token_config) def run(): + # Log startup message + logger.info("Starting sclack...") + # Determine log file location (might be in ~/.sclack_logs if ~/.sclack is a file) + log_dir = os.path.expanduser('~/.sclack') + if os.path.exists(log_dir) and os.path.isfile(log_dir): + log_file = os.path.expanduser('~/.sclack_logs/sclack.log') + else: + log_file = os.path.expanduser('~/.sclack/sclack.log') + logger.info(f"Log file location: {log_file}") + json_config = {} config_file = os.path.join( os.path.dirname(os.path.realpath(__file__)), 'config.json') diff --git a/sclack/components.py b/sclack/components.py index 6990ee5..6da9129 100644 --- a/sclack/components.py +++ b/sclack/components.py @@ -279,10 +279,23 @@ def header(self): @header.setter def header(self, header): - urwid.disconnect_signal(self.body, 'set_date', self._header.on_set_date) + # Disconnect old header signal + if hasattr(self, '_header') and self._header: + urwid.disconnect_signal(self.body, 'set_date', self._header.on_set_date) + + # Update our internal reference self._header = header - urwid.connect_signal(self.body, 'set_date', self._header.on_set_date) - self.set_header(self._header) + + # Connect new header signal + if header: + urwid.connect_signal(self.body, 'set_date', self._header.on_set_date) + + # Update the frame's header directly without triggering property setter + # Use the parent class's _contents directly + if hasattr(self, '_contents'): + self._contents['header'] = (header, None) + # Force urwid to recognize the change + self._invalidate() class ChatBoxMessages(urwid.ListBox): diff --git a/sclack/config.json b/sclack/config.json index 28181ff..f6ec9f5 100644 --- a/sclack/config.json +++ b/sclack/config.json @@ -31,29 +31,29 @@ "browser": "" }, "icons": { - "block": "\u258C", - "block_bottom": "\u2598", - "block_top": "\u2596", - "channel": "\uF198", - "divider": "\uE0B1", - "full_divider": "\uE0B0", - "edit": "\uF040", - "email": "\uF42F", - "full_star": "\uF005", - "heart": "\uF004", - "keyboard": "\uF11C", - "line_star": "\uF006", - "offline": "\uF10C", - "online": "\uF111", - "person": "\uF415", - "phone": "\uF095", - "pin": "\uF435", - "private_channel": "\uF023", - "skype": "\uF17E", - "square": "\uF445", - "snooze": "\uF9B1", - "status": "\uF075", - "timezone": "\uF0AC", - "alarm_snooze": "\ufb8c" + "block": "▌", + "block_bottom": "▘", + "block_top": "▖", + "channel": "#", + "divider": "▸", + "full_divider": "▸", + "edit": "*", + "email": "@", + "full_star": "★", + "heart": "♥", + "keyboard": "⌨", + "line_star": "☆", + "offline": "○", + "online": "●", + "person": "P", + "phone": "☎", + "pin": "P", + "private_channel": "L", + "skype": "S", + "square": "■", + "snooze": "Z", + "status": "S", + "timezone": "T", + "alarm_snooze": "A" } } diff --git a/sclack/image.py b/sclack/image.py index f07e5c3..81e3d6a 100644 --- a/sclack/image.py +++ b/sclack/image.py @@ -56,7 +56,10 @@ def img_to_ansi(path, width, height): if height: command.extend(['-H', str(height)]) try: - ansi_text = subprocess.check_output(command) + # Suppress stderr to prevent "unable to load" messages from appearing on screen + import os + with open(os.devnull, 'w') as devnull: + ansi_text = subprocess.check_output(command, stderr=devnull) except: ansi_text = None return ansi_text diff --git a/sclack/store.py b/sclack/store.py index c07a9fd..ef4b5d5 100644 --- a/sclack/store.py +++ b/sclack/store.py @@ -1,11 +1,46 @@ +import json +import logging +import os from slackclient import SlackClient +# Set up logging to file +log_dir = os.path.expanduser('~/.sclack') +# Check if ~/.sclack exists and is a file (old config file) +if os.path.exists(log_dir) and os.path.isfile(log_dir): + # If it's a file, create a different log directory + log_dir = os.path.expanduser('~/.sclack_logs') + os.makedirs(log_dir, exist_ok=True) +elif not os.path.exists(log_dir): + # If it doesn't exist, create it as a directory + os.makedirs(log_dir, exist_ok=True) +# If it's already a directory, use it as-is + +log_file = os.path.join(log_dir, 'sclack.log') + +logging.basicConfig( + level=logging.INFO, # Changed from DEBUG to INFO to reduce verbosity + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(log_file) + # Removed StreamHandler to prevent logs from appearing on screen + ], + force=True # Override any existing logging configuration +) +logger = logging.getLogger(__name__) + +# Disable verbose logging from other libraries +logging.getLogger('urllib3').setLevel(logging.WARNING) +logging.getLogger('requests').setLevel(logging.WARNING) +logging.getLogger('slackclient').setLevel(logging.WARNING) +logging.getLogger('websocket').setLevel(logging.WARNING) + class State: def __init__(self): self.channels = [] self.dms = [] self.groups = [] + self.mpims = [] # Multi-person DMs (group DMs) self.stars = [] self.messages = [] self.users = [] @@ -32,6 +67,16 @@ def __init__(self, workspaces, config): self.workspaces = workspaces slack_token = workspaces[0][1] self.slack_token = slack_token + + # Log token type for debugging + if slack_token.startswith('xoxb-'): + logger.info("Using BOT token (xoxb-) - messages will only be visible if sent to the bot") + logger.warning("For user impersonation, use a USER token (xoxs- or xoxp-)") + elif slack_token.startswith('xoxs-') or slack_token.startswith('xoxp-'): + logger.info("Using USER token - will see all messages the user can see") + else: + logger.warning(f"Unknown token type (starts with: {slack_token[:5]}...)") + self.slack = SlackClient(slack_token) self.state = State() self.cache = Cache() @@ -71,15 +116,62 @@ def find_or_load_bot(self, bot_id): return self.state.bots[bot_id] def load_messages(self, channel_id): - history = self.slack.api_call( - 'conversations.history', - channel=channel_id - ) - self.state.messages = history['messages'] - self.state.has_more = history.get('has_more', False) - self.state.is_limited = history.get('is_limited', False) - self.state.pin_count = history['pin_count'] - self.state.messages.reverse() + all_messages = [] + cursor = None + + while True: + params = {'channel': channel_id, 'limit': 200} # Max is 200 per request + if cursor: + params['cursor'] = cursor + + history = self.slack.api_call('conversations.history', **params) + + # Debug: log response details + if not history.get('ok', False): + error = history.get('error', 'Unknown error') + logger.warning(f"conversations.history API call failed for {channel_id}: {error}") + if error == 'missing_scope': + logger.warning("Your token is missing required scopes. You need:") + logger.warning(" - channels:history (for public channels)") + logger.warning(" - groups:history (for private channels)") + logger.warning(" - im:history (for direct messages)") + logger.warning(" - mpim:history (for group DMs)") + break + + if 'messages' in history: + messages = history['messages'] + all_messages.extend(messages) + logger.debug(f"Loaded {len(messages)} messages (total so far: {len(all_messages)})") + + # Check if there are more messages + if history.get('has_more', False): + cursor = history.get('response_metadata', {}).get('next_cursor') + if not cursor: + break + else: + break + else: + # API call succeeded but no messages field + logger.warning(f"conversations.history succeeded but no 'messages' field in response") + logger.debug(f"Response keys: {list(history.keys())}") + break + + if all_messages: + self.state.messages = all_messages + self.state.has_more = False # We've loaded all messages + self.state.is_limited = False + # Get pin_count from channel info, not from messages + self.state.pin_count = self.state.channel.get('pin_count', 0) if hasattr(self.state, 'channel') and self.state.channel else 0 + logger.info(f"Loaded {len(self.state.messages)} total messages for channel {channel_id}") + if len(self.state.messages) > 0: + logger.debug(f"First message timestamp: {self.state.messages[0].get('ts', 'N/A')}") + logger.debug(f"Last message timestamp: {self.state.messages[-1].get('ts', 'N/A')}") + self.state.messages.reverse() + else: + self.state.messages = [] + self.state.has_more = False + self.state.is_limited = False + self.state.pin_count = 0 def is_valid_channel_id(self, channel_id): """ @@ -115,12 +207,37 @@ def is_group(self, channel_id): def get_channel_info(self, channel_id): if channel_id[0] in ('C', 'G'): - return self.slack.api_call('conversations.info', channel=channel_id)['channel'] + response = self.slack.api_call('conversations.info', channel=channel_id) + if response.get('ok', False) and 'channel' in response: + return response['channel'] + # Log error for debugging + if not response.get('ok', False): + logger.warning(f"conversations.info failed for {channel_id}: {response.get('error', 'Unknown error')}") + return None elif channel_id[0] == 'D': - return self.slack.api_call('im.info', channel=channel_id)['channel'] + # Try conversations.info first (newer API) + response = self.slack.api_call('conversations.info', channel=channel_id) + if response.get('ok', False) and 'channel' in response: + return response['channel'] + # Fallback to im.info (legacy API) + response = self.slack.api_call('im.info', channel=channel_id) + if response.get('ok', False) and 'channel' in response: + return response['channel'] + # Log error for debugging + if not response.get('ok', False): + logger.warning(f"im.info failed for {channel_id}: {response.get('error', 'Unknown error')}") + return None + return None def get_channel_members(self, channel_id): - return self.slack.api_call('conversations.members', channel=channel_id) + # For DMs, members API doesn't make sense, return empty structure + if channel_id[0] == 'D': + return {'ok': True, 'members': []} + response = self.slack.api_call('conversations.members', channel=channel_id) + if not response.get('ok', False): + # Return empty structure if API call fails + return {'ok': True, 'members': []} + return response def mark_read(self, channel_id, ts): if self.is_group(channel_id): @@ -138,31 +255,160 @@ def set_snooze(self, snoozed_time): return self.slack.api_call('dnd.setSnooze', num_minutes=snoozed_time) def load_channel(self, channel_id): + # Check if it's an mpim (group DM) first + for mpim in self.state.mpims: + if mpim.get('id') == channel_id: + # Use the mpim data we already have + self.state.channel = mpim.copy() + # Try to get additional info + channel_info = self.get_channel_info(channel_id) + if channel_info: + # Merge additional info if available + self.state.channel.update(channel_info) + self.state.members = self.get_channel_members(channel_id) + self.state.did_render_new_messages = self.state.channel.get('unread_count_display', 0) == 0 + return + if channel_id[0] in ('C', 'G', 'D'): - self.state.channel = self.get_channel_info(channel_id) - self.state.members = self.get_channel_members(channel_id) - self.state.did_render_new_messages = self.state.channel.get('unread_count_display', 0) == 0 + # For DMs, try to use data we already have from load_channels + if channel_id[0] == 'D': + # Find the DM in our already loaded DMs + for dm in self.state.dms: + if dm.get('id') == channel_id: + # Use the DM data we already have + self.state.channel = dm.copy() + # Try to get additional info, but don't fail if it doesn't work + channel_info = self.get_channel_info(channel_id) + if channel_info: + # Merge additional info if available + self.state.channel.update(channel_info) + self.state.members = self.get_channel_members(channel_id) + self.state.did_render_new_messages = self.state.channel.get('unread_count_display', 0) == 0 + return + + # For channels and groups, or if DM not found in loaded list + channel_info = self.get_channel_info(channel_id) + if channel_info: + self.state.channel = channel_info + self.state.members = self.get_channel_members(channel_id) + self.state.did_render_new_messages = self.state.channel.get('unread_count_display', 0) == 0 + else: + # If channel info can't be loaded, set empty state + self.state.channel = {} + self.state.members = {} + self.state.did_render_new_messages = True + else: + # Try to get info for unknown channel type (might be mpim with different ID format) + channel_info = self.get_channel_info(channel_id) + if channel_info: + self.state.channel = channel_info + self.state.members = self.get_channel_members(channel_id) + self.state.did_render_new_messages = self.state.channel.get('unread_count_display', 0) == 0 + else: + # If channel info can't be loaded, set empty state + self.state.channel = {} + self.state.members = {} + self.state.did_render_new_messages = True def load_channels(self): - conversations = self.slack.api_call( + # First, try to get channels using conversations.list (gets all channels user can see) + channels_response = self.slack.api_call( + 'conversations.list', + exclude_archived=True, + limit=1000, + types='public_channel,private_channel' + ) + + channels_list = [] + if channels_response.get('ok', False): + channels_list = channels_response.get('channels', []) + logger.info(f"conversations.list returned {len(channels_list)} channels") + else: + error = channels_response.get('error', 'Unknown error') + logger.warning(f"conversations.list API call failed: {error}") + if error == 'missing_scope': + logger.warning("Your token is missing required scopes. You need:") + logger.warning(" - channels:read (for public channels)") + logger.warning(" - groups:read (for private channels)") + + # Also get DMs using users.conversations + dms_response = self.slack.api_call( 'users.conversations', exclude_archived=True, - limit=1000, # 1k is max limit - types='public_channel,private_channel,im' - )['channels'] - - for channel in conversations: - # Public channel - if channel.get('is_channel', False): - self.state.channels.append(channel) - # Private channel - elif channel.get('is_group', False): - self.state.channels.append(channel) - # Direct message - elif channel.get('is_im', False) and not channel.get('is_user_deleted', False): - self.state.dms.append(channel) - self.state.channels.sort(key=lambda channel: channel['name']) - self.state.dms.sort(key=lambda dm: dm['created']) + limit=1000, + types='im' + ) + + dms_list = [] + if dms_response.get('ok', False): + dms_list = dms_response.get('channels', []) + logger.info(f"users.conversations returned {len(dms_list)} DMs") + else: + error = dms_response.get('error', 'Unknown error') + logger.warning(f"users.conversations API call failed: {error}") + + # Get group DMs (mpim) using conversations.list + mpim_response = self.slack.api_call( + 'conversations.list', + exclude_archived=True, + limit=1000, + types='mpim' + ) + + mpim_list = [] + if mpim_response.get('ok', False): + mpim_list = mpim_response.get('channels', []) + logger.info(f"conversations.list returned {len(mpim_list)} group DMs (mpim)") + else: + error = mpim_response.get('error', 'Unknown error') + logger.warning(f"conversations.list (mpim) API call failed: {error}") + if error == 'missing_scope': + logger.warning("Your token is missing required scopes. You need:") + logger.warning(" - mpim:read (for group direct messages)") + + # Process channels + channels_count = 0 + for conv in channels_list: + # Log first channel structure for debugging + if channels_count == 0: + logger.info(f"Sample channel structure: {json.dumps({k: v for k, v in list(conv.items())[:15]}, indent=2)}") + + # Add channel if it's public or private + if conv.get('is_channel', False) or conv.get('is_group', False): + self.state.channels.append(conv) + channels_count += 1 + logger.info(f"Added channel: {conv.get('name', conv.get('id', 'unknown'))}") + elif 'name' in conv: + # Fallback: if it has a name, treat as channel + logger.info(f"Fallback: treating conversation with name '{conv.get('name')}' as channel") + self.state.channels.append(conv) + channels_count += 1 + + # Process DMs + dms_count = 0 + for conv in dms_list: + # Direct message - has 'is_im' field set to True + if conv.get('is_im', False) is True: + if not conv.get('is_user_deleted', False): + self.state.dms.append(conv) + dms_count += 1 + + # Process group DMs (mpim) + mpim_count = 0 + for conv in mpim_list: + if conv.get('is_mpim', False): + self.state.mpims.append(conv) + mpim_count += 1 + # Log group DM name for debugging + group_name = conv.get('name', conv.get('id', 'unknown')) + logger.info(f"Added group DM: {group_name}") + + logger.info(f"Processed {channels_count} channels, {dms_count} DMs, and {mpim_count} group DMs") + + # Only sort channels that have a 'name' key + self.state.channels.sort(key=lambda channel: channel.get('name', '')) + self.state.dms.sort(key=lambda dm: dm.get('created', 0)) + self.state.mpims.sort(key=lambda mpim: mpim.get('created', 0)) def load_groups(self): self.state.groups = filter(lambda c: c['is_group'] is True, self.slack.api_call('conversations.list')) @@ -172,16 +418,26 @@ def load_stars(self): Load stars :return: """ - self.state.stars = list(filter( - lambda star: star.get('type', '') in ('channel', 'im', 'group',), - self.slack.api_call('stars.list')['items'] - )) + response = self.slack.api_call('stars.list') + if response.get('ok', False) and 'items' in response: + self.state.stars = list(filter( + lambda star: star.get('type', '') in ('channel', 'im', 'group',), + response['items'] + )) + else: + # If API call fails or doesn't return items, set empty list + self.state.stars = [] def load_users(self): - self.state.users = list(filter( - lambda user: not user.get('deleted', False), - self.slack.api_call('users.list')['members'] - )) + response = self.slack.api_call('users.list') + if response.get('ok', False) and 'members' in response: + self.state.users = list(filter( + lambda user: not user.get('deleted', False), + response['members'] + )) + else: + # If API call fails or doesn't return members, set empty list + self.state.users = [] self._users_dict = {} self._bots_dict = {} for user in self.state.users: @@ -209,10 +465,13 @@ def edit_message(self, channel_id, ts, text): ) def post_message(self, channel_id, message): + # With user tokens (xoxs-/xoxp-), messages are automatically sent as the user + # With bot tokens (xoxb-), as_user=True makes it appear as the bot + # For user impersonation, use a user token return self.slack.api_call( 'chat.postMessage', channel=channel_id, - as_user=True, + as_user=True, # Ensures message appears as the user (works with user tokens) link_names=True, text=message ) diff --git a/setup.py b/setup.py index c0418a1..10d4ba7 100644 --- a/setup.py +++ b/setup.py @@ -22,11 +22,11 @@ }, packages=find_packages(), install_requires=[ - 'asyncio', - 'urwid>2', - 'pyperclip', + 'urwid>=3.0.3', + 'pyperclip==1.6.2', 'requests', - 'slackclient', - 'urwid_readline' + 'slackclient==1.2.1', + 'urwid_readline', + 'websocket-client==0.47.0' ] )