This is an automated email from the ASF dual-hosted git repository. meonkeys pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/fineract-chat-archive.git
commit 022c0dda73a727374855638a4c428e29f8c17047 Author: Adam Monsen <[email protected]> AuthorDate: Mon Feb 9 16:34:19 2026 -0800 use env vars instead of properties file for config * move slack token into ArchiveConfig class * log all config on startup * remove Optional; guarantee callers have useful config instance --- Readme.md | 22 ++++--- config/archive.properties | 23 ------- .../fineract/chat/archive/ArchiveConfig.java | 55 ++++++++-------- .../fineract/chat/archive/ChatArchiveApp.java | 63 ++++++------------- .../fineract/chat/archive/ArchiveConfigTest.java | 73 ++++++++-------------- 5 files changed, 83 insertions(+), 153 deletions(-) diff --git a/Readme.md b/Readme.md index 72c0396..32312d8 100644 --- a/Readme.md +++ b/Readme.md @@ -4,27 +4,27 @@ A standalone tool for archiving Slack messages into a static site. ## Local run -``` +```bash ./gradlew updateChatArchive ``` -Configuration: -- File: `config/archive.properties` -- Key: `channels.allowlist` (comma-separated channel names, e.g. `#fineract`) -- Key: `output.dir` (relative path for site output, default `docs`) -- Key: `state.dir` (relative path for cursor state, default `state`) -- Key: `fetch.lookback.days` (how many days to re-fetch, default `1`) +Configuration (environment variables): + +- `SLACK_TOKEN` (required; Slack Bot token) ⚠️ warning: this keep secret! +- `CHANNELS_ALLOWLIST` (required; comma-separated channel names, e.g. `#fineract`) +- `OUTPUT_DIR` (optional; relative path for site output, default `docs`) +- `STATE_DIR` (optional; relative path for cursor state, default `state`) +- `LOOKBACK_DAYS` (optional; how many days to re-fetch, default `1`) Output: + - Daily pages: `docs/daily/<channel>/<YYYY-MM-DD>.md` - Channel index: `docs/daily/<channel>/index.md` - Global index: `docs/index.md` - Thread replies are rendered below parent messages with a simple prefix. -Environment: -- `SLACK_TOKEN` (Slack Bot token) - Slack app setup: + 1. Create a Slack app (from scratch) in the target workspace. 2. Add a bot user. 3. Add the required scopes listed below. @@ -32,9 +32,11 @@ Slack app setup: 5. Copy the Bot User OAuth Token (starts with `xoxb-`) into `SLACK_TOKEN`. Required Slack scopes: + - `channels:read` (list public channels) - `channels:history` (read public channel history) - `users:read` (resolve user display names) + Permalinks are resolved via `chat.getPermalink`. If Slack returns `missing_scope`, add the scope Slack reports and re-install the app. diff --git a/config/archive.properties b/config/archive.properties deleted file mode 100644 index 76bfb28..0000000 --- a/config/archive.properties +++ /dev/null @@ -1,23 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -channels.allowlist=#fineract -output.dir=docs -state.dir=state -fetch.lookback.days=2 - diff --git a/src/main/java/org/apache/fineract/chat/archive/ArchiveConfig.java b/src/main/java/org/apache/fineract/chat/archive/ArchiveConfig.java index cbd62d5..37b6a42 100644 --- a/src/main/java/org/apache/fineract/chat/archive/ArchiveConfig.java +++ b/src/main/java/org/apache/fineract/chat/archive/ArchiveConfig.java @@ -18,58 +18,55 @@ */ package org.apache.fineract.chat.archive; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.List; -import java.util.Optional; -import java.util.Properties; final class ArchiveConfig { + static final String SLACK_TOKEN_ENV = "SLACK_TOKEN"; - static final String CHANNELS_ALLOWLIST_KEY = "channels.allowlist"; - static final String OUTPUT_DIR_KEY = "output.dir"; - static final String STATE_DIR_KEY = "state.dir"; - static final String LOOKBACK_DAYS_KEY = "fetch.lookback.days"; + static final String CHANNELS_ALLOWLIST_ENV = "CHANNELS_ALLOWLIST"; + static final String OUTPUT_DIR_ENV = "OUTPUT_DIR"; + static final String STATE_DIR_ENV = "STATE_DIR"; + static final String LOOKBACK_DAYS_ENV = "LOOKBACK_DAYS"; + static final String LOG_LEVEL_ENV = "LOG_LEVEL"; + + static final String DEFAULT_OUTPUT_DIR = "docs"; + static final String DEFAULT_STATE_DIR = "state"; static final int DEFAULT_LOOKBACK_DAYS = 1; + private final String slackToken; private final List<String> channelAllowlist; private final Path outputDir; private final Path stateDir; private final int lookbackDays; - private ArchiveConfig(List<String> channelAllowlist, Path outputDir, Path stateDir, + private ArchiveConfig(String slackToken, List<String> channelAllowlist, Path outputDir, Path stateDir, int lookbackDays) { + this.slackToken = slackToken; this.channelAllowlist = List.copyOf(channelAllowlist); this.outputDir = outputDir; this.stateDir = stateDir; this.lookbackDays = lookbackDays; } - static Optional<ArchiveConfig> load(Path configPath) throws IOException { - if (!Files.exists(configPath)) { - return Optional.empty(); - } - - Properties properties = new Properties(); - try (InputStream inputStream = Files.newInputStream(configPath)) { - properties.load(inputStream); - } + static ArchiveConfig fromEnv() { + return fromValues(System.getenv(SLACK_TOKEN_ENV), System.getenv(CHANNELS_ALLOWLIST_ENV), System.getenv(OUTPUT_DIR_ENV), + System.getenv(STATE_DIR_ENV), System.getenv(LOOKBACK_DAYS_ENV)); + } - String allowlist = properties.getProperty(CHANNELS_ALLOWLIST_KEY, ""); + static ArchiveConfig fromValues(String slackTokenValue, String allowlist, String outputDirValue, + String stateDirValue, String lookbackDaysValue) { + String slackToken = slackTokenValue != null ? slackTokenValue.trim() : ""; List<String> channels = parseAllowlist(allowlist); - if (channels.isEmpty()) { - return Optional.empty(); - } + Path outputDir = Path.of(outputDirValue != null ? outputDirValue.trim() : DEFAULT_OUTPUT_DIR); + Path stateDir = Path.of(stateDirValue != null ? stateDirValue.trim() : DEFAULT_STATE_DIR); + int lookbackDays = parseLookbackDays(lookbackDaysValue); + return new ArchiveConfig(slackToken, channels, outputDir, stateDir, lookbackDays); + } - String outputDirValue = properties.getProperty(OUTPUT_DIR_KEY, "docs").trim(); - Path outputDir = Path.of(outputDirValue); - String stateDirValue = properties.getProperty(STATE_DIR_KEY, "state").trim(); - Path stateDir = Path.of(stateDirValue); - int lookbackDays = parseLookbackDays(properties.getProperty(LOOKBACK_DAYS_KEY)); - return Optional.of(new ArchiveConfig(channels, outputDir, stateDir, lookbackDays)); + String slackToken() { + return slackToken; } List<String> channelAllowlist() { diff --git a/src/main/java/org/apache/fineract/chat/archive/ChatArchiveApp.java b/src/main/java/org/apache/fineract/chat/archive/ChatArchiveApp.java index 30e64b6..d66a797 100644 --- a/src/main/java/org/apache/fineract/chat/archive/ChatArchiveApp.java +++ b/src/main/java/org/apache/fineract/chat/archive/ChatArchiveApp.java @@ -25,56 +25,39 @@ import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.TreeMap; -import java.util.Set; +import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; public final class ChatArchiveApp { - static final String SLACK_TOKEN_ENV = "SLACK_TOKEN"; - private static final Logger LOG = Logger.getLogger(ChatArchiveApp.class.getName()); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter .ofPattern("HH:mm:ss"); - private ChatArchiveApp() {} - public static void main(String[] args) { - Optional<String> slackToken = readEnv(SLACK_TOKEN_ENV); - if (slackToken.isEmpty()) { - LOG.info("SLACK_TOKEN is not set. Skipping archive update."); - return; - } + ArchiveConfig config = ArchiveConfig.fromEnv(); - Path configPath = Path.of("config", "archive.properties"); - Optional<ArchiveConfig> config; - try { - config = ArchiveConfig.load(configPath); - } catch (IOException ex) { - LOG.log(Level.SEVERE, "Failed to read config at " + configPath + ".", ex); + String slackToken = config.slackToken(); + if (slackToken.isEmpty()) { + LOG.info(ArchiveConfig.SLACK_TOKEN_ENV + " is not set. Skipping archive update."); return; } - if (config.isEmpty()) { - LOG.info("Config file missing or channel allowlist empty. Skipping archive update."); + if (config.channelAllowlist().isEmpty()) { + LOG.info(ArchiveConfig.CHANNELS_ALLOWLIST_ENV + " is not set. Skipping archive update."); return; } - ArchiveConfig archiveConfig = config.get(); - LOG.info("Loaded config for " + archiveConfig.channelAllowlist().size() - + " channel(s)."); + LOG.info("Using state dir [" + config.stateDir() + "]"); + LOG.info("Using output dir [" + config.outputDir() + "]"); + LOG.info("Loaded config for " + config.channelAllowlist().size() + " channel(s)."); + LOG.info("Will fetch messages for the past " + config.lookbackDays() + " day(s)."); SlackApiClient slackApiClient = new SlackApiClient(); SlackApiClient.AuthTestResponse authResponse; try { - authResponse = slackApiClient.authTest(slackToken.get()); + authResponse = slackApiClient.authTest(config.slackToken()); } catch (IOException ex) { LOG.log(Level.SEVERE, "Slack auth.test call failed.", ex); return; @@ -93,7 +76,7 @@ public final class ChatArchiveApp { SlackApiClient.ConversationsListResponse channelsResponse; try { - channelsResponse = slackApiClient.listPublicChannels(slackToken.get()); + channelsResponse = slackApiClient.listPublicChannels(config.slackToken()); } catch (IOException ex) { LOG.log(Level.SEVERE, "Slack conversations.list call failed.", ex); return; @@ -110,7 +93,7 @@ public final class ChatArchiveApp { List<SlackApiClient.SlackChannel> channels = channelsResponse.channels(); ChannelResolver.ChannelResolution resolution = ChannelResolver.resolve( - archiveConfig.channelAllowlist(), channels); + config.channelAllowlist(), channels); if (!resolution.missing().isEmpty()) { LOG.warning("Allowlisted channel(s) not found: " @@ -125,14 +108,14 @@ public final class ChatArchiveApp { LOG.info("Resolved " + resolution.resolved().size() + " channel(s)."); Instant windowStart = Instant.now() - .minus(Duration.ofDays(archiveConfig.lookbackDays())); + .minus(Duration.ofDays(config.lookbackDays())); String windowOldest = SlackTimestamp.formatEpochSecond(windowStart.getEpochSecond()); - CursorStore cursorStore = new CursorStore(archiveConfig.stateDir()); + CursorStore cursorStore = new CursorStore(config.stateDir()); CursorStore.CursorState cursorState = loadCursorState(cursorStore); Map<String, String> cursors = new HashMap<>(cursorState.channels()); - Path dailyRoot = archiveConfig.outputDir().resolve("daily"); + Path dailyRoot = config.outputDir().resolve("daily"); Map<String, String> permalinkCache = new HashMap<>(); Map<String, String> userCache = new HashMap<>(); Map<String, List<SlackMessage>> threadRepliesCache = new HashMap<>(); @@ -144,7 +127,7 @@ public final class ChatArchiveApp { cursors.get(channelId)); SlackApiClient.ConversationsHistoryResponse historyResponse; try { - historyResponse = slackApiClient.listChannelMessages(slackToken.get(), channelId, + historyResponse = slackApiClient.listChannelMessages(config.slackToken(), channelId, oldest); } catch (IOException ex) { LOG.log(Level.SEVERE, "Slack conversations.history call failed for channel " @@ -174,7 +157,7 @@ public final class ChatArchiveApp { for (Map.Entry<LocalDate, List<SlackMessage>> entry : grouped.entrySet()) { LocalDate date = entry.getKey(); List<MarkdownRenderer.Row> rows = toRows(entry.getValue(), channelId, - slackToken.get(), slackApiClient, permalinkCache, userCache, + config.slackToken(), slackApiClient, permalinkCache, userCache, threadRepliesCache); String page = MarkdownRenderer.renderDailyPage(channel.name(), date, rows); Path pagePath = dailyRoot.resolve(channel.name()).resolve(date + ".md"); @@ -201,14 +184,6 @@ public final class ChatArchiveApp { } } - static Optional<String> readEnv(String name) { - String value = System.getenv(name); - if (value == null || value.isBlank()) { - return Optional.empty(); - } - return Optional.of(value); - } - private static CursorStore.CursorState loadCursorState(CursorStore cursorStore) { try { return cursorStore.load().orElseGet(CursorStore.CursorState::empty); diff --git a/src/test/java/org/apache/fineract/chat/archive/ArchiveConfigTest.java b/src/test/java/org/apache/fineract/chat/archive/ArchiveConfigTest.java index 8b868df..1521052 100644 --- a/src/test/java/org/apache/fineract/chat/archive/ArchiveConfigTest.java +++ b/src/test/java/org/apache/fineract/chat/archive/ArchiveConfigTest.java @@ -21,73 +21,52 @@ package org.apache.fineract.chat.archive; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.Optional; import org.junit.jupiter.api.Test; class ArchiveConfigTest { @Test - void loadReturnsConfigWhenAllowlistPresent() throws Exception { - Path tempFile = Files.createTempFile("archive", ".properties"); - String content = String.join("\n", - "channels.allowlist=#fineract, dev", - "output.dir=docs", - "state.dir=state", - "fetch.lookback.days=3", - ""); - Files.writeString(tempFile, content, StandardCharsets.UTF_8); + void nominalConfig() { - Optional<ArchiveConfig> config = ArchiveConfig.load(tempFile); + ArchiveConfig config = ArchiveConfig.fromValues("dummy", "#fineract, dev", "docs", + "state", "3"); - assertTrue(config.isPresent()); - assertEquals(2, config.get().channelAllowlist().size()); - assertEquals("fineract", config.get().channelAllowlist().get(0)); - assertEquals("dev", config.get().channelAllowlist().get(1)); - assertEquals(Path.of("docs"), config.get().outputDir()); - assertEquals(Path.of("state"), config.get().stateDir()); - assertEquals(3, config.get().lookbackDays()); + assertEquals(2, config.channelAllowlist().size()); + assertEquals("fineract", config.channelAllowlist().get(0)); + assertEquals("dev", config.channelAllowlist().get(1)); + assertEquals(Path.of("docs"), config.outputDir()); + assertEquals(Path.of("state"), config.stateDir()); + assertEquals(3, config.lookbackDays()); } @Test - void loadReturnsEmptyWhenFileMissing() throws Exception { - Path missingFile = Path.of("config", - "missing-" + System.nanoTime() + ".properties"); + void missingSlackToken() { + ArchiveConfig config = ArchiveConfig.fromValues(" ",null, "docs", "state", "1"); - Optional<ArchiveConfig> config = ArchiveConfig.load(missingFile); - - assertTrue(config.isEmpty()); + assertTrue(config.slackToken().isEmpty()); } @Test - void loadReturnsEmptyWhenAllowlistIsEmpty() throws Exception { - Path tempFile = Files.createTempFile("archive-empty", ".properties"); - String content = String.join("\n", - "channels.allowlist=", - "output.dir=docs", - ""); - Files.writeString(tempFile, content, StandardCharsets.UTF_8); - - Optional<ArchiveConfig> config = ArchiveConfig.load(tempFile); + void emptyAllowList() { + ArchiveConfig config = ArchiveConfig.fromValues("dummy","", "docs", "state", "1"); - assertTrue(config.isEmpty()); + assertTrue(config.channelAllowlist().isEmpty()); } @Test - void loadUsesDefaultLookbackDaysWhenInvalid() throws Exception { - Path tempFile = Files.createTempFile("archive-invalid", ".properties"); - String content = String.join("\n", - "channels.allowlist=#fineract", - "fetch.lookback.days=0", - ""); - Files.writeString(tempFile, content, StandardCharsets.UTF_8); + void fromValuesUsesDefaultLookbackDaysWhenInvalid() { + ArchiveConfig config = ArchiveConfig.fromValues("dummy","#fineract", "docs", "state", "0"); + + assertEquals(ArchiveConfig.DEFAULT_LOOKBACK_DAYS, config.lookbackDays()); + } - Optional<ArchiveConfig> config = ArchiveConfig.load(tempFile); + @Test + void fromValuesUsesDefaultsWhenOptionalValuesNull() { + ArchiveConfig config = ArchiveConfig.fromValues("dummy", "#fineract", null, null, null); - assertTrue(config.isPresent()); - assertEquals(ArchiveConfig.DEFAULT_LOOKBACK_DAYS, config.get().lookbackDays()); + assertEquals(Path.of(ArchiveConfig.DEFAULT_OUTPUT_DIR), config.outputDir()); + assertEquals(Path.of(ArchiveConfig.DEFAULT_STATE_DIR), config.stateDir()); + assertEquals(ArchiveConfig.DEFAULT_LOOKBACK_DAYS, config.lookbackDays()); } } -
