Compare commits

...
Sign in to create a new pull request.

11 commits
master ... ng

5 changed files with 217 additions and 84 deletions

13
.woodpecker/latest.yaml Normal file
View file

@ -0,0 +1,13 @@
steps:
- name: build
image: woodpeckerci/plugin-docker-buildx:5.2.2
settings:
platforms: linux/amd64
repo: git.ztsh.eu/stawros/slack-exporter
registry: git.ztsh.eu
tags: latest
username: stawros
password:
from_secret: git_pat
when:
- event: [tag, push, manual]

View file

@ -3,10 +3,11 @@ LABEL authors="stawros"
RUN mkdir /app
COPY slack-exporter/requirements.txt /app/
COPY slack-exporter/exporter.py /app/
COPY requirements.txt /app/
COPY exporter_ng.py /app/
COPY tui.py /app/
RUN pip3 uninstall urllib3
RUN pip3 install -r /app/requirements.txt
CMD ["python3", "/app/exporter.py"]
CMD ["python3", "/app/tui.py"]

View file

@ -1,16 +1,15 @@
import json
import os
import sys
import requests
import json
from timeit import default_timer
from dataclasses import dataclass
from datetime import datetime
import argparse
from time import sleep
from typing import List, Optional, Dict, Any
import requests
from dotenv import load_dotenv
from pathvalidate import sanitize_filename
from time import sleep
from dataclasses import dataclass
from typing import List, Optional, Dict, Any, Iterator, Tuple
from abc import ABC, abstractmethod
@dataclass
class SlackConfig:
@ -30,8 +29,10 @@ class SlackConfig:
except KeyError:
raise ValueError("Brak SLACK_USER_TOKEN w zmiennych środowiskowych")
class SlackAPI:
"""Klasa do komunikacji z API Slacka"""
def __init__(self, config: SlackConfig):
self.config = config
self.headers = {"Authorization": f"Bearer {config.user_token}"}
@ -130,6 +131,7 @@ class SlackAPI:
params["channel"] = channel
return self.paginated_get("files.list", params, "files")
@dataclass
class SlackUser:
"""Reprezentacja użytkownika Slack"""
@ -148,12 +150,11 @@ class SlackUser:
@classmethod
def from_dict(cls, data: Dict) -> 'SlackUser':
profile = data.get("profile", {})
return cls(
id=data["id"],
name=data.get("name", ""),
real_name=profile.get("real_name"),
display_name=profile.get("display_name"),
real_name=data.get("real_name"),
display_name=data.get("display_name"),
is_admin=data.get("is_admin", False),
is_owner=data.get("is_owner", False),
is_primary_owner=data.get("is_primary_owner", False),
@ -189,17 +190,22 @@ class SlackUser:
return ", ".join(parts)
class SlackChannel:
"""Reprezentacja kanału Slack"""
def __init__(self, data: Dict):
def __init__(self, data: Dict, users: Dict[str, SlackUser]):
self.id = data["id"]
self.name = data.get("name", "")
self.is_private = data.get("is_private", False)
self.is_im = data.get("is_im", False)
self.is_mpim = data.get("is_mpim", False)
self.is_group = data.get("is_group", False)
self.creator_id = data.get("creator")
self.user_id = data.get("user")
self._creator_id = data.get("creator")
self._user_id = data.get("user")
self.user = users[self._user_id].get_display_name() if self._user_id and self._user_id in users else "(nieznany)"
self._mpim_users = self._list_mpim({u.name: u for u in users.values()}) if self.is_mpim else []
self.label = self._create_label(users)
@property
def type(self) -> str:
@ -212,7 +218,24 @@ class SlackChannel:
else:
return "channel"
def format(self, users: Dict[str, SlackUser]) -> str:
@property
def short_label(self) -> str:
if self.is_im:
return f"(DM) {self.user}"
elif self.is_mpim:
return f"(MPDM) {', '.join(self._mpim_users)}"
return self.name
def _list_mpim(self, users: Dict[str, SlackUser]) -> List[str]:
result = []
for part in self.name.split("-"):
name = users.get(part)
if name:
result.append(name.get_display_name())
return result
def _create_label(self, users: Dict[str, SlackUser]) -> str:
"""Formatuje informacje o kanale"""
parts = [f"[{self.id}]"]
@ -224,13 +247,14 @@ class SlackChannel:
parts.append(self.type)
if self.creator_id and self.creator_id in users:
parts.append(f"created by {users[self.creator_id].name}")
elif self.user_id and self.user_id in users:
parts.append(f"with {users[self.user_id].name}")
if self._creator_id and self._creator_id in users:
parts.append(f"created by {users[self._creator_id].get_display_name()}")
elif self._user_id and self._user_id in users:
parts.append(f"with {users[self._user_id].get_display_name()}")
return " ".join(parts)
@dataclass
class SlackFile:
"""Reprezentacja pliku Slack"""
@ -246,8 +270,10 @@ class SlackFile:
url_private=data.get("url_private", "")
)
class SlackMessage:
"""Reprezentacja wiadomości Slack"""
def __init__(self, data: Dict, users: Dict[str, SlackUser]):
self.timestamp = float(data["ts"])
self.text = data.get("text", "[no message content]")
@ -295,9 +321,11 @@ class SlackMessage:
return message + "\n\n" + "*" * 24 + "\n\n"
class SlackExporter:
"""Główna klasa eksportera"""
def __init__(self, config: SlackConfig, output_dir: str):
def __init__(self, config: SlackConfig, output_dir: str = 'out'):
self.api = SlackAPI(config)
self.timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
self.output_dir = self._set_output_dir(output_dir)
@ -314,12 +342,14 @@ class SlackExporter:
def _load_users(self) -> Dict[str, SlackUser]:
"""Ładuje użytkowników"""
users_data = self.api.get_users()
# users_data = json.load(open("out/user_list.json", "r", encoding="utf-8"))
return {u["id"]: SlackUser.from_dict(u) for u in users_data}
def _load_channels(self) -> List[SlackChannel]:
"""Ładuje kanały"""
channels_data = self.api.get_channels()
return [SlackChannel(ch) for ch in channels_data]
# channels_data = json.load(open("out/channel_list.json", "r", encoding="utf-8"))
return [SlackChannel(ch, self.users) for ch in channels_data]
def _save_data(self, data: Any, filename: str, as_json: bool = False):
"""Zapisuje dane do pliku"""
@ -337,12 +367,25 @@ class SlackExporter:
else:
f.write(data)
def export_channels(self, channels: List[str]):
channels_map = {ch.id: ch for ch in self.channels}
self.export_channel_list(True)
self.export_user_list(True)
for channel_id in channels:
print(f"Eksport {channels_map.get(channel_id).label}...")
print(f"[{channel_id}] Historia")
self.export_channel_history(channel_id)
print(f"[{channel_id}] Odpowiedzi")
self.export_channel_replies(channel_id)
print(f"[{channel_id}] Pliki")
self.export_channel_files(channel_id)
def export_channel_list(self, as_json: bool = False):
"""Eksportuje listę kanałów"""
if as_json:
data = [vars(ch) for ch in self.channels]
else:
data = "\n".join(ch.format(self.users) for ch in self.channels)
data = "\n".join(ch.label for ch in self.channels)
self._save_data(data, "channel_list", as_json)
def export_user_list(self, as_json: bool = False):
@ -354,10 +397,13 @@ class SlackExporter:
self._save_data(data, "user_list", as_json)
def export_channel_history(self, channel_id: str, oldest: Optional[str] = None,
latest: Optional[str] = None, as_json: bool = False):
"""Eksportuje historię kanału"""
latest: Optional[str] = None):
history = self.api.get_channel_history(channel_id, oldest, latest)
self._export_channel_history(channel_id, history, True)
self._export_channel_history(channel_id, history, False)
def _export_channel_history(self, channel_id: str, history: List[Dict], as_json: bool):
"""Eksportuje historię kanału"""
if as_json:
data = history
else:
@ -374,16 +420,18 @@ class SlackExporter:
self._save_data(data, f"channel_{channel_id}", as_json)
def export_channel_replies(self, channel_id: str, oldest: Optional[str] = None,
latest: Optional[str] = None, as_json: bool = False):
"""Eksportuje wątki w kanale"""
latest: Optional[str] = None):
history = self.api.get_channel_history(channel_id, oldest, latest)
thread_messages = [msg for msg in history if "reply_count" in msg]
all_replies = []
for msg in thread_messages:
replies = self.api.get_replies(channel_id, msg["ts"])
all_replies.extend(replies)
self._export_channel_replies(channel_id, all_replies, True)
self._export_channel_replies(channel_id, all_replies, False)
def _export_channel_replies(self, channel_id: str, all_replies: List[Dict], as_json: bool):
"""Eksportuje wątki w kanale"""
if as_json:
data = all_replies
else:
@ -397,14 +445,14 @@ class SlackExporter:
data = header + "".join(msg.format(True) for msg in messages)
self._save_data(data, f"channel-replies_{channel_id}", as_json)
def export_channel_files(self, channel_id: Optional[str] = None):
"""Eksportuje pliki w kanale"""
files = [SlackFile.from_dict(f) for f in self.api.get_files(channel_id)]
for file in files:
filename = f"{file.id}-{sanitize_filename(file.name)}"
self.download_file(filename, file.url_private)
def download_file(self, filename: str, url: str, attempts: int = 10) -> bool:
if attempts == 0:
return False
@ -417,63 +465,13 @@ class SlackExporter:
with open(target, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
print(f"Zapisano plik {target}")
return True
except requests.exceptions.RequestException as e:
print(f"Error downloading file {filename}: {e}. {attempts} attempts left.")
return self.download_file(filename, url, attempts - 1)
def main():
parser = argparse.ArgumentParser(description="Eksporter danych ze Slacka")
parser.add_argument("-o", help="Katalog wyjściowy (jeśli pusty, wyświetla na stdout)")
parser.add_argument("--lc", action="store_true", help="Lista wszystkich konwersacji")
parser.add_argument("--lu", action="store_true", help="Lista wszystkich użytkowników")
parser.add_argument("--json", action="store_true", help="Wynik w formacie JSON")
parser.add_argument("-c", action="store_true", help="Historia wszystkich dostępnych konwersacji")
parser.add_argument("--ch", help="Z -c, ogranicza eksport do podanego ID kanału")
parser.add_argument("--fr", help="Z -c, timestamp początku zakresu (Unix)")
parser.add_argument("--to", help="Z -c, timestamp końca zakresu (Unix)")
parser.add_argument("-r", action="store_true", help="Pobierz wątki ze wszystkich konwersacji")
parser.add_argument("--files", action="store_true", help="Pobierz wszystkie pliki")
args = parser.parse_args()
if args.files and not args.o:
print("Opcja --files wymaga określenia katalogu wyjściowego (-o)")
sys.exit(1)
try:
config = SlackConfig.from_env()
exporter = SlackExporter(config)
if args.o:
exporter.set_output_dir(args.o)
exporter.load_users()
exporter.load_channels()
if args.lc:
exporter.export_channel_list(args.json)
if args.lu:
exporter.export_user_list(args.json)
if args.c or args.r:
channel_ids = [args.ch] if args.ch else [ch.id for ch in exporter.channels]
for channel_id in channel_ids:
if args.c:
exporter.export_channel_history(channel_id, args.fr, args.to, args.json)
if args.r:
exporter.export_channel_replies(channel_id, args.fr, args.to, args.json)
if args.files and args.o:
# TODO: Implementacja pobierania plików
print("Funkcja pobierania plików jeszcze nie zaimplementowana")
except Exception as e:
print(f"Błąd: {e}")
sys.exit(1)
if __name__ == "__main__":
# main()
config = SlackConfig.from_env()
exporter = SlackExporter(config, 'out')
exporter = SlackExporter(config)

View file

@ -2,3 +2,5 @@ Flask~=1.1.2
requests~=2.24.0
python-dotenv~=0.15.0
pathvalidate~=2.5.2
textual~=3.1.1

119
tui.py Normal file
View file

@ -0,0 +1,119 @@
import sys
from textual import work
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.screen import Screen
from textual.widgets import Header, Button, ListView, ListItem, Footer, Label
from exporter_ng import SlackExporter, SlackConfig
class SlackExporterScreen(Screen):
"""Ekran główny eksportera Slack"""
def __init__(self, exporter: SlackExporter, *args, **kwargs):
super().__init__(*args, **kwargs)
self.exporter = exporter
self.selected_channels = set()
def compose(self) -> ComposeResult:
"""Komponuje widgety na ekranie"""
yield Header(show_clock=True)
yield Container(
ListView(*[
ListItem(Label(ch.short_label, id=ch.id))
for ch in self.exporter.channels
], id="channel-list"),
Button("Eksportuj zaznaczone", variant="primary", id="export-btn"),
id="main-container"
)
yield Footer()
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Obsługuje zaznaczenie elementu listy"""
item = event.item
item_id = item.children[0].id
if item_id in self.selected_channels:
self.selected_channels.remove(item_id)
item.remove_class("selected")
else:
self.selected_channels.add(item_id)
item.add_class("selected")
@work(exclusive=True)
async def export_channels(self):
"""Eksportuje zaznaczone kanały"""
if not self.selected_channels:
self.notify("Nie wybrano żadnych kanałów")
return
# self.notify("Rozpoczynam eksport...")
# self.exporter.export_channels(list(self.selected_channels))
# # for channel_id in self.selected_channels:
# # self.notify(f"Eksportuję kanał {channel_id}...")
# # self.exporter.export_channel_history(channel_id)
# self.notify("Eksport zakończony")
self.app.exit(return_code=8080)
async def on_button_pressed(self, event: Button.Pressed) -> None:
"""Obsługuje kliknięcie przycisku"""
if event.button.id == "export-btn":
self.export_channels()
class SlackTUI(App):
"""Główna klasa interfejsu użytkownika"""
CSS = """
#main-container {
layout: vertical;
height: 100%;
padding: 1;
}
ListView {
height: 1fr;
border: solid green;
}
.selected {
background: $accent;
color: $text;
}
Button {
margin: 1;
width: 100%;
}
"""
def __init__(self, exporter: SlackExporter):
super().__init__()
self.exporter = exporter
self._screen = SlackExporterScreen(self.exporter)
def on_mount(self) -> None:
"""Wywoływane przy montowaniu aplikacji"""
self.push_screen(self._screen)
def get_selection(self):
return self._screen.selected_channels
def get_return_code(self):
return self._return_code
def run_tui(exporter: SlackExporter):
"""Uruchamia interfejs użytkownika"""
app = SlackTUI(exporter)
app.run()
if app.get_return_code() == 8080:
exporter.export_channels(list(app.get_selection()))
if __name__ == "__main__":
try:
config = SlackConfig.from_env()
exporter = SlackExporter(config)
run_tui(exporter)
except Exception as e:
print(f"Błąd: {e}")
sys.exit(1)