Skip to content

Commit 5d8342e

Browse files
authored
Merge pull request #9 from makoni/develop
Update configuration management and add new dependencies
2 parents 60918a2 + d1b398d commit 5d8342e

File tree

10 files changed

+349
-26
lines changed

10 files changed

+349
-26
lines changed

.github/copilot-instructions.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
## Quick orientation
2+
3+
This repository implements a Telegram bot that watches iOS App Store releases and notifies subscribers. The codebase is split into three logical modules:
4+
5+
- `ReleaseInformerBot` (executable): main bot logic, Telegram handlers, Vapor app scaffolding — see `Sources/ReleaseInformerBot/configure.swift` and `Sources/ReleaseInformerBot/entrypoint.swift`.
6+
- `ReleaseWatcher` (service): background watcher that iterates subscriptions and sends notifications — see `Sources/ReleaseWatcher/ReleaseWatcher.swift`.
7+
- `Shared` (library): models and persistence helpers (CouchDB) — see `Sources/Shared/Managers/DBManager.swift` and `Sources/Shared/Models/Subscription.swift`.
8+
9+
Read these files first to understand the end-to-end flow.
10+
11+
## Big-picture data flow
12+
13+
1. User sends command to the Telegram bot (handled by `BotHandlers` in `Sources/ReleaseInformerBot/TGBot/BotHandlers.swift`).
14+
2. Handlers call `DBManager` (`Sources/Shared/Managers/DBManager.swift`) to create/list/delete `Subscription` documents in CouchDB.
15+
3. `ReleaseWatcher` periodically fetches all subscriptions (`getAllSubscriptions()`), queries the iTunes API via `SearchManager` (`Sources/Shared/Managers/SearchManager.swift`) and updates subscriptions with new versions.
16+
4. When a new version is detected, `ReleaseWatcher` uses the `TGBot` instance (set in `configure.swift` via `BotActor`) to send formatted HTML messages.
17+
18+
Key integration points: `DBManager` <-> CouchDB, `SearchManager` <-> iTunes API (https://itunes.apple.com), `AsyncHttpTGClient` <-> Telegram API.
19+
20+
## Concurrency & runtime patterns to keep in mind
21+
22+
- Actors are used heavily for thread-safety: `DBManager`, `SearchManager`, `ReleaseWatcher`, `BotActor`. Prefer `async/await` and actor isolation when changing shared state.
23+
- `ReleaseWatcher` uses two `DispatchSourceTimer`s: a 5-minute `timer` for bulk runs and a 2-second `appCheckTimer` that processes a queue; it enforces small delays (2s) between individual chat notifications.
24+
- The `entrypoint` contains commented-out code for attempting to install NIO as the global executor — be cautious if enabling it as it can change shutdown behavior.
25+
26+
## Persistence and DB conventions
27+
28+
- Database name: `release_bot` (hard-coded in `DBManager`).
29+
- `DBManager.setupIfNeed()` will create the DB and add a design doc with two views: `by_bundle` and `by_chat`. The design doc id is `_design/list`.
30+
- `Subscription` maps CouchDB keys with coding keys (bundle ID stored as `bundle_id`) — see `Sources/Shared/Models/Subscription.swift`.
31+
- When adding versions, the project keeps the last 5 versions (see `DBManager.addNewVersion(...)`).
32+
33+
If you change the CouchDB credentials/host you must update `fileprivate let couchDBClient = CouchDBClient(...)` inside `DBManager.swift`.
34+
35+
## Bot & Telegram specifics
36+
37+
- Bot token is read from environment variable `apiKey` in `configure.swift`.
38+
- The project uses `SwiftTelegramSdk` and a custom `AsyncHttpTGClient` (`Sources/ReleaseInformerBot/TGBot/AsyncHttpTGClient.swift`) which:
39+
- Defaults to multipart/form-data for most requests.
40+
- Limits response body reads to ~1MB for Telegram responses.
41+
- Throws a descriptive `BotError` when Telegram returns ok=false.
42+
- Messages use HTML parse mode; handlers build HTML strings (see `BotHandlers.makeSearchResultsMessage` and `makeListMessage`). Preserve HTML encoding when editing messages.
43+
44+
## Search & iTunes quirks
45+
46+
- `SearchManager` performs HTTP GETs against the iTunes Search/Lookup APIs and contains a `processJSONString(_:)` sanitiser that:
47+
- Escapes control characters inside JSON strings
48+
- Replaces non-breaking spaces
49+
This behaviour exists because the iTunes API sometimes returns characters that break `JSONDecoder`. If you modify parsing, keep this sanitiser or add robust tests.
50+
51+
## How to run, build, and test
52+
53+
- Build: `swift build`
54+
- Run locally: set the Telegram token and run the executable:
55+
56+
```bash
57+
export apiKey="<YOUR_TELEGRAM_BOT_TOKEN>"
58+
swift run
59+
```
60+
61+
- Tests: `swift test` (tests use `Application.make(.testing)` from VaporTesting; see `Tests/ReleaseInformerBotTests/ReleaseInformerBotTests.swift`).
62+
63+
Notes: CouchDB must be reachable for the app to initialize successfully; `DBManager.setupIfNeed()` will try to create DB and views on startup and will exit the process on failure (see `configure.swift`).
64+
65+
## Common change patterns & examples
66+
67+
- Adding a new bot command: implement a handler in `BotHandlers` and register it from `addHandlers(bot:)`.
68+
- To send a message from background code: obtain the `TGBot` via `BotActor` (see `configure.swift`) and call `bot.sendMessage(...)` from an async context.
69+
- To add a new view or query in CouchDB: add the JavaScript map to the design document in `DBManager.setupIfNeed()` and ensure any changes keep existing docs compatible.
70+
71+
## Files to inspect when debugging
72+
73+
- `Sources/ReleaseInformerBot/configure.swift` — initialization order (DB -> bot -> watchers -> routes)
74+
- `Sources/Shared/Managers/DBManager.swift` — DB access patterns and CouchDB client usage
75+
- `Sources/ReleaseWatcher/ReleaseWatcher.swift` — timers, run loop, and notification flow
76+
- `Sources/ReleaseInformerBot/TGBot/*` — bot client, handlers, and HTTP client
77+
78+
## Quick tips for agents
79+
80+
- Preserve actor isolation when editing shared state.
81+
- Respect the 2s sleeps used to rate-limit notifications in `ReleaseWatcher` and `BotHandlers` — removing them may trigger API rate limits.
82+
- When modifying message text, keep HTML tags and avoid unescaped user content.
83+
- Add unit tests for parsing changes in `SearchManager.processJSONString(_:)` when touching JSON handling.
84+
85+
If anything here looks incomplete or you want more examples (e.g., an example test or a short diagram), tell me which area to expand.

Package.resolved

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import PackageDescription
44
let package = Package(
55
name: "ReleaseInformerBot",
66
platforms: [
7-
.macOS(.v13)
7+
.macOS(.v15)
88
],
99
dependencies: [
1010
// 💧 A server-side Swift web framework.
@@ -13,6 +13,7 @@ let package = Package(
1313
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
1414
.package(url: "https://github.com/nerzh/swift-telegram-sdk.git", .upToNextMajor(from: "3.8.0")),
1515
.package(url: "https://github.com/makoni/couchdb-swift.git", from: "2.1.0"),
16+
.package(url: "https://github.com/apple/swift-configuration", .upToNextMinor(from: "0.1.0")),
1617
],
1718
targets: [
1819
.target(
@@ -35,6 +36,7 @@ let package = Package(
3536
.product(name: "NIOCore", package: "swift-nio"),
3637
.product(name: "NIOPosix", package: "swift-nio"),
3738
.product(name: "SwiftTelegramSdk", package: "swift-telegram-sdk"),
39+
.product(name: "Configuration", package: "swift-configuration"),
3840
.target(name: "Shared"),
3941
.target(name: "ReleaseWatcher")
4042
]
@@ -44,6 +46,7 @@ let package = Package(
4446
dependencies: [
4547
.target(name: "ReleaseInformerBot"),
4648
.product(name: "VaporTesting", package: "vapor"),
49+
.product(name: "Configuration", package: "swift-configuration"),
4750
]
4851
)
4952
]

README.md

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ The project uses carefully selected, production-grade dependencies:
5353
- **[Swift NIO](https://github.com/apple/swift-nio)** `2.65.0+` - Non-blocking networking foundation
5454
- **[SwiftTelegramSdk](https://github.com/nerzh/swift-telegram-sdk)** `3.8.0+` - Telegram Bot API client
5555
- **[CouchDB Swift](https://github.com/makoni/couchdb-swift)** `2.1.0+` - CouchDB client library
56+
- **[Swift Configuration](https://github.com/apple/swift-configuration)** `0.1.0+` - Unified configuration reader for environment variables and files
5657

5758
### Development Dependencies
5859
- **VaporTesting** - Testing utilities for Vapor applications
@@ -61,6 +62,7 @@ The project uses carefully selected, production-grade dependencies:
6162

6263
### Prerequisites
6364
- Swift 6.0+
65+
- macOS 15.0+ or Linux with Swift 6 toolchain
6466
- CouchDB instance (local or remote)
6567
- Telegram Bot Token (from [@BotFather](https://t.me/botfather))
6668

@@ -72,9 +74,13 @@ The project uses carefully selected, production-grade dependencies:
7274
cd ReleaseInformerBot
7375
```
7476

75-
2. **Set environment variables**:
77+
2. **Set environment variables** (override as needed):
7678
```bash
77-
export apiKey="YOUR_TELEGRAM_BOT_TOKEN"
79+
export TELEGRAM_API_KEY="YOUR_TELEGRAM_BOT_TOKEN"
80+
export COUCH_HOST="127.0.0.1"
81+
export COUCH_USER="admin"
82+
export COUCH_PASSWORD=""
83+
export COUCH_PORT=5984
7884
```
7985

8086
3. **Build and run**:
@@ -88,21 +94,60 @@ The project uses carefully selected, production-grade dependencies:
8894
swift test
8995
```
9096

97+
### Configuration
98+
99+
The bot now uses [Swift Configuration](https://github.com/apple/swift-configuration) to resolve settings from multiple sources. The lookup order is:
100+
1. Environment variables (using uppercase keys such as `TELEGRAM_API_KEY` or `COUCH_HOST`)
101+
2. Optional JSON configuration file
102+
3. Hard-coded defaults
103+
104+
Provide a JSON file at `config/config.json` (or set `RELEASE_INFORMER_CONFIG_PATH` to an absolute path) to manage settings locally:
105+
106+
```json
107+
{
108+
"telegram": {
109+
"apiKey": "YOUR_TELEGRAM_BOT_TOKEN"
110+
},
111+
"couch": {
112+
"protocol": "http",
113+
"host": "127.0.0.1",
114+
"port": 5984,
115+
"user": "admin",
116+
"password": "",
117+
"requestsTimeout": 30
118+
},
119+
"runtime": {
120+
"bootstrapServices": true
121+
}
122+
}
123+
```
124+
125+
Set `runtime.bootstrapServices` to `false` (default in `testing` environment) to skip bot initialization and external service connections while still registering routes.
126+
91127
### Production Configuration
92128

93129
For production deployments, ensure:
94130
- Set `LOG_LEVEL=info` or `LOG_LEVEL=warning`
95-
- Configure CouchDB credentials in `DBManager.swift`
131+
- Provide CouchDB credentials via configuration (environment variables or JSON file)
96132
- Use proper secrets management for the Telegram bot token
97133
- Set up monitoring and health checks on port 8080
134+
- Ensure the `couchdb-swift_CouchDBClient.resources` bundle is deployed alongside the binary. When you build with SwiftPM (e.g., `swift build --swift-sdk x86_64-swift-linux-musl -c release`), copy both of these paths to the server directory where you host the executable:
135+
- `.build/x86_64-swift-linux-musl/release/ReleaseInformerBot`
136+
- `.build/x86_64-swift-linux-musl/release/couchdb-swift_CouchDBClient.resources`
137+
A minimal deployment directory on the server should look like:
138+
```
139+
/home/user/ReleaseInformerBot
140+
├── ReleaseInformerBot
141+
└── couchdb-swift_CouchDBClient.resources/
142+
```
98143

99144
## CouchDB Setup
100145

101146
The bot will automatically create the required CouchDB database (`release_bot`) and design document with the necessary views on startup.
102147

103148
**Automatic Setup:**
104149

105-
- The `DBManager` includes a `setupIfNeed()` method that checks for the existence of the database and required views, and creates them if they do not exist. No manual setup is required for most users—just ensure your CouchDB instance is running and credentials are configured in `DBManager.swift`.
150+
- The `DBManager` includes a `setupIfNeed()` method that checks for the existence of the database and required views, and creates them if they do not exist. No manual setup is required for most users—just ensure your CouchDB instance is running and credentials are provided through configuration.
106151

107152
**Manual Setup (optional):**
108153

Sources/ReleaseInformerBot/TGBot/BotHandlers.swift

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ import Logging
1414
let searchManager = SearchManager()
1515

1616
final class BotHandlers {
17-
static func addHandlers(bot: TGBot) async {
17+
static func addHandlers(bot: TGBot, dbManager: DBManager) async {
1818
await help(bot: bot)
19-
await list(bot: bot)
19+
await list(bot: bot, dbManager: dbManager)
2020
await search(bot: bot)
21-
await add(bot: bot)
22-
await del(bot: bot)
21+
await add(bot: bot, dbManager: dbManager)
22+
await del(bot: bot, dbManager: dbManager)
2323
await commandShowButtonsHandler(bot: bot)
24-
await buttonsActionHandler(bot: bot)
24+
await buttonsActionHandler(bot: bot, dbManager: dbManager)
2525
}
2626

2727
private static func help(bot: TGBot) async {
@@ -31,11 +31,10 @@ final class BotHandlers {
3131
})
3232
}
3333

34-
private static func list(bot: TGBot) async {
34+
private static func list(bot: TGBot, dbManager: DBManager) async {
3535
await bot.dispatcher.add(
3636
TGCommandHandler(commands: ["/list"]) { update in
3737
guard let chatID = update.message?.chat.id else { return }
38-
3938
var subscriptions = try await dbManager.search(byChatID: chatID)
4039

4140
if subscriptions.count > 10 {
@@ -76,7 +75,7 @@ final class BotHandlers {
7675
})
7776
}
7877

79-
private static func del(bot: TGBot) async {
78+
private static func del(bot: TGBot, dbManager: DBManager) async {
8079
await bot.dispatcher.add(
8180
TGCommandHandler(commands: ["/del"]) { update in
8281
guard let chatID = update.message?.chat.id else { return }
@@ -94,7 +93,7 @@ final class BotHandlers {
9493
})
9594
}
9695

97-
private static func add(bot: TGBot) async {
96+
private static func add(bot: TGBot, dbManager: DBManager) async {
9897
await bot.dispatcher.add(
9998
TGCommandHandler(commands: ["/add"]) { update in
10099
guard let chatID = update.message?.chat.id else { return }
@@ -138,7 +137,7 @@ final class BotHandlers {
138137
})
139138
}
140139

141-
private static func buttonsActionHandler(bot: TGBot) async {
140+
private static func buttonsActionHandler(bot: TGBot, dbManager: DBManager) async {
142141
await bot.dispatcher.add(
143142
TGCallbackQueryHandler(pattern: "help") { update in
144143
bot.log.info("help")

0 commit comments

Comments
 (0)