Add non-interactive CLI mode and gitignore for local/runtime artifacts.
Support --team/--channel/--output flags for scripted exports, add renew.sh wrapper, and exclude config, cookies, results, and sqlite index from git. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Local credentials and session state
|
||||||
|
config.json
|
||||||
|
cookies.pkl
|
||||||
|
|
||||||
|
# Export output and search index
|
||||||
|
results/
|
||||||
|
mattermost_index.sqlite
|
||||||
|
|
||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.cursor/
|
||||||
+168
-23
@@ -1,7 +1,9 @@
|
|||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
import random
|
import random
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, date, timezone
|
from datetime import datetime, date, timezone
|
||||||
from typing import Tuple, Dict, List, Optional
|
from typing import Tuple, Dict, List, Optional
|
||||||
@@ -246,6 +248,15 @@ def _login_to_mattermost_via_gitlab(session: requests.Session, host: str, gitlab
|
|||||||
return "MMAUTHTOKEN" in session.cookies
|
return "MMAUTHTOKEN" in session.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def try_get_token_from_cookies(host: str, cookie_file: str = COOKIE_FILE_DEFAULT) -> Optional[str]:
|
||||||
|
session = requests.Session()
|
||||||
|
if not _load_cookies(session, cookie_file):
|
||||||
|
return None
|
||||||
|
if not _get_current_user(session, host):
|
||||||
|
return None
|
||||||
|
return session.cookies.get("MMAUTHTOKEN")
|
||||||
|
|
||||||
|
|
||||||
def get_token_via_gitlab(host: str, gitlab_url: str, username: str, password: str,
|
def get_token_via_gitlab(host: str, gitlab_url: str, username: str, password: str,
|
||||||
cookie_file: str = COOKIE_FILE_DEFAULT) -> Optional[str]:
|
cookie_file: str = COOKIE_FILE_DEFAULT) -> Optional[str]:
|
||||||
session = requests.Session()
|
session = requests.Session()
|
||||||
@@ -310,6 +321,71 @@ def save_users_json(output_base: str, user_profiles: List[dict]) -> str:
|
|||||||
return str(output_path)
|
return str(output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_direct_message_channels(d: Driver, channels: List[dict], my_user_id: str,
|
||||||
|
user_id_to_name: Dict[str, str]) -> None:
|
||||||
|
for channel in channels:
|
||||||
|
if channel["type"] != "D":
|
||||||
|
continue
|
||||||
|
|
||||||
|
user_ids = channel["name"].split("__")
|
||||||
|
other_user_id = user_ids[1] if user_ids[0] == my_user_id else user_ids[0]
|
||||||
|
if other_user_id not in user_id_to_name:
|
||||||
|
try:
|
||||||
|
user_id_to_name[other_user_id] = d.users.get_user(other_user_id)["username"]
|
||||||
|
except Exception:
|
||||||
|
user_id_to_name[other_user_id] = channel.get("display_name") or other_user_id
|
||||||
|
channel["display_name"] = user_id_to_name[other_user_id]
|
||||||
|
|
||||||
|
|
||||||
|
def get_channels_for_team(d: Driver, team: dict, my_user_id: str,
|
||||||
|
user_id_to_name: Dict[str, str]) -> List[dict]:
|
||||||
|
channels = d.channels.get_channels_for_user(my_user_id, team["id"])
|
||||||
|
_enrich_direct_message_channels(d, channels, my_user_id, user_id_to_name)
|
||||||
|
return sorted(channels, key=lambda x: x["display_name"].lower())
|
||||||
|
|
||||||
|
|
||||||
|
def find_team(d: Driver, my_user_id: str, team_name: str) -> dict:
|
||||||
|
teams = d.teams.get_user_teams(my_user_id)
|
||||||
|
for team in teams:
|
||||||
|
if team["name"] == team_name:
|
||||||
|
return team
|
||||||
|
available = ", ".join(team["name"] for team in teams)
|
||||||
|
raise SystemExit(f"Team {team_name!r} not found. Available teams: {available}")
|
||||||
|
|
||||||
|
|
||||||
|
def find_channels(channels: List[dict], channel_spec: str) -> List[dict]:
|
||||||
|
if channel_spec.lower() == "all":
|
||||||
|
return channels
|
||||||
|
|
||||||
|
requested = [part.strip() for part in channel_spec.split(",") if part.strip()]
|
||||||
|
if not requested:
|
||||||
|
raise SystemExit("No channels specified (use channel names or 'all').")
|
||||||
|
|
||||||
|
by_name = {}
|
||||||
|
by_display = {}
|
||||||
|
for channel in channels:
|
||||||
|
by_name[channel["name"].lower()] = channel
|
||||||
|
by_display[channel["display_name"].lower()] = channel
|
||||||
|
|
||||||
|
selected = []
|
||||||
|
missing = []
|
||||||
|
for name in requested:
|
||||||
|
key = name.lower()
|
||||||
|
channel = by_name.get(key) or by_display.get(key)
|
||||||
|
if channel is None:
|
||||||
|
missing.append(name)
|
||||||
|
else:
|
||||||
|
selected.append(channel)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
available = ", ".join(channel["display_name"] for channel in channels)
|
||||||
|
raise SystemExit(
|
||||||
|
f"Channel(s) not found: {', '.join(missing)}. "
|
||||||
|
f"Available channels: {available}"
|
||||||
|
)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
def select_team(d: Driver, my_user_id: str) -> str:
|
def select_team(d: Driver, my_user_id: str) -> str:
|
||||||
print("Downloading all team information... ", end="")
|
print("Downloading all team information... ", end="")
|
||||||
teams = d.teams.get_user_teams(my_user_id)
|
teams = d.teams.get_user_teams(my_user_id)
|
||||||
@@ -325,23 +401,7 @@ def select_team(d: Driver, my_user_id: str) -> str:
|
|||||||
def select_channel(d: Driver, team: str, my_user_id: str, user_id_to_name: Dict[str, str],
|
def select_channel(d: Driver, team: str, my_user_id: str, user_id_to_name: Dict[str, str],
|
||||||
verbose: bool = False) -> List[str]:
|
verbose: bool = False) -> List[str]:
|
||||||
print("Downloading all channel information... ", end="")
|
print("Downloading all channel information... ", end="")
|
||||||
channels = d.channels.get_channels_for_user(my_user_id, team["id"])
|
channels = get_channels_for_team(d, team, my_user_id, user_id_to_name)
|
||||||
# Add display name to direct messages
|
|
||||||
for channel in channels:
|
|
||||||
if channel["type"] != "D":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# The channel name consists of two user ids connected by a double underscore
|
|
||||||
user_ids = channel["name"].split("__")
|
|
||||||
other_user_id = user_ids[1] if user_ids[0] == my_user_id else user_ids[0]
|
|
||||||
if other_user_id not in user_id_to_name:
|
|
||||||
try:
|
|
||||||
user_id_to_name[other_user_id] = d.users.get_user(other_user_id)["username"]
|
|
||||||
except Exception:
|
|
||||||
user_id_to_name[other_user_id] = channel.get("display_name") or other_user_id
|
|
||||||
channel["display_name"] = user_id_to_name[other_user_id]
|
|
||||||
# Sort channels by name for easier search
|
|
||||||
channels = sorted(channels, key=lambda x: x["display_name"].lower())
|
|
||||||
print(f"Found {len(channels)} channels!")
|
print(f"Found {len(channels)} channels!")
|
||||||
|
|
||||||
for i_channel, channel in enumerate(channels):
|
for i_channel, channel in enumerate(channels):
|
||||||
@@ -561,7 +621,47 @@ def get_config_from_json(config_filename: str = "config.json") -> dict:
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def complete_config(config: dict, config_filename: str = "config.json") -> dict:
|
def complete_config_noninteractive(config: dict) -> dict:
|
||||||
|
required = ("host", "login_mode")
|
||||||
|
missing = [key for key in required if not config.get(key)]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(
|
||||||
|
f"Missing required config field(s) for non-interactive mode: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
login_mode = config["login_mode"]
|
||||||
|
if login_mode not in ("password", "token", "gitlab"):
|
||||||
|
raise SystemExit(f"Unsupported login_mode in config: {login_mode!r}")
|
||||||
|
|
||||||
|
token = config.get("token")
|
||||||
|
if login_mode == "gitlab":
|
||||||
|
token = token or try_get_token_from_cookies(
|
||||||
|
config["host"], config.get("cookie_file", COOKIE_FILE_DEFAULT)
|
||||||
|
)
|
||||||
|
if token:
|
||||||
|
config["token"] = token
|
||||||
|
elif login_mode == "password":
|
||||||
|
if not config.get("username") or not config.get("password"):
|
||||||
|
raise SystemExit(
|
||||||
|
"Non-interactive password login requires 'username' and 'password' in config."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"No valid token in config or cookies. "
|
||||||
|
"Run interactively once to authenticate."
|
||||||
|
)
|
||||||
|
|
||||||
|
if "download_files" not in config:
|
||||||
|
config["download_files"] = True
|
||||||
|
|
||||||
|
config["password"] = None
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def complete_config(config: dict, config_filename: str = "config.json", *, interactive: bool = True) -> dict:
|
||||||
|
if not interactive:
|
||||||
|
return complete_config_noninteractive(config)
|
||||||
|
|
||||||
config_changed = False
|
config_changed = False
|
||||||
if config.get("host", False):
|
if config.get("host", False):
|
||||||
print(f"Using host '{config['host']}' from config")
|
print(f"Using host '{config['host']}' from config")
|
||||||
@@ -709,13 +809,48 @@ def find_mmauthtoken_firefox(host):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download Mattermost channel history to local JSON files.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
default="config.json",
|
||||||
|
help="Path to config file (default: config.json).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--team",
|
||||||
|
help="Team name for non-interactive export (e.g. esr-daily).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--channel",
|
||||||
|
help="Channel name(s), comma-separated, or 'all' for every channel in the team.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
help="Output directory (overrides config output_base and default date-based path).",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
config_filename = "config.json"
|
args = parse_args()
|
||||||
|
non_interactive = bool(args.team or args.channel)
|
||||||
|
if non_interactive and not (args.team and args.channel):
|
||||||
|
print("Non-interactive mode requires both --team and --channel.", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
config_filename = args.config
|
||||||
config = get_config_from_json(config_filename)
|
config = get_config_from_json(config_filename)
|
||||||
config = complete_config(config, config_filename)
|
config = complete_config(config, config_filename, interactive=not non_interactive)
|
||||||
|
|
||||||
incremental = config.get("incremental", bool(config.get("last_export_at")))
|
incremental = config.get("incremental", bool(config.get("last_export_at")))
|
||||||
if incremental and config.get("last_export_at"):
|
if args.output:
|
||||||
|
output_base = args.output
|
||||||
|
after = config.get("after")
|
||||||
|
before = config.get("before")
|
||||||
|
merge = False
|
||||||
|
elif incremental and config.get("last_export_at"):
|
||||||
print(f"Incremental update from {config['last_export_at']}")
|
print(f"Incremental update from {config['last_export_at']}")
|
||||||
output_base = config.get("output_base") or ("results/" + date.today().strftime("%Y%m%d"))
|
output_base = config.get("output_base") or ("results/" + date.today().strftime("%Y%m%d"))
|
||||||
after = config["last_export_at"]
|
after = config["last_export_at"]
|
||||||
@@ -739,6 +874,8 @@ if __name__ == '__main__':
|
|||||||
)
|
)
|
||||||
apply_throttle(d, config)
|
apply_throttle(d, config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if non_interactive:
|
||||||
|
raise SystemExit(f"Authentication failed: {e}") from e
|
||||||
# If token-based authentication failed, drop the token and ask
|
# If token-based authentication failed, drop the token and ask
|
||||||
# the user to rerun the script so that fresh credentials can be
|
# the user to rerun the script so that fresh credentials can be
|
||||||
# obtained or entered.
|
# obtained or entered.
|
||||||
@@ -751,8 +888,16 @@ if __name__ == '__main__':
|
|||||||
raise
|
raise
|
||||||
user_id_to_name, my_user_id, user_profiles = get_users(d)
|
user_id_to_name, my_user_id, user_profiles = get_users(d)
|
||||||
save_users_json(output_base, user_profiles)
|
save_users_json(output_base, user_profiles)
|
||||||
team = select_team(d, my_user_id)
|
if non_interactive:
|
||||||
channels = select_channel(d, team, my_user_id, user_id_to_name)
|
team = find_team(d, my_user_id, args.team)
|
||||||
|
print(f"Selected team {team['name']}")
|
||||||
|
channels = get_channels_for_team(d, team, my_user_id, user_id_to_name)
|
||||||
|
print(f"Found {len(channels)} channels")
|
||||||
|
channels = find_channels(channels, args.channel)
|
||||||
|
print("Selected channel(s):", ", ".join(channel["display_name"] for channel in channels))
|
||||||
|
else:
|
||||||
|
team = select_team(d, my_user_id)
|
||||||
|
channels = select_channel(d, team, my_user_id, user_id_to_name)
|
||||||
for i_channel, channel in enumerate(channels):
|
for i_channel, channel in enumerate(channels):
|
||||||
print(f"Start export of channel {i_channel + 1}/{len(channels)}")
|
print(f"Start export of channel {i_channel + 1}/{len(channels)}")
|
||||||
export_channel(d, channel, user_id_to_name, output_base, config["download_files"],
|
export_channel(d, channel, user_id_to_name, output_base, config["download_files"],
|
||||||
|
|||||||
Reference in New Issue
Block a user