nippotion 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/dist/cli.js +588 -0
  4. package/package.json +52 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Kenichi Takahashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # nippotion
2
+
3
+ A CLI tool that delivers daily reports (diary entries) written in a Notion database to Slack channels based on their labels, every morning. Runs with **`npx nippotion` and a single config file**.
4
+
5
+ - Delivers **the previous business day's** entries on weekday mornings (weekends are skipped; set `"holidays": "jp"` to also skip Japanese holidays via [@holiday-jp/holiday_jp](https://github.com/holiday-jp/holiday_jp). Monday delivers Friday's entries)
6
+ - Routes entries to multiple Slack channels based on their labels (multi-select)
7
+ - Randomly picks one entry each time to feature as the "pickup" entry
8
+ - Timezone, holiday skipping, language (Japanese/English), and Slack message wording are all configurable (works for non-Japan offices and English-speaking workspaces too)
9
+
10
+ ```bash
11
+ npx nippotion --config nippotion.json
12
+ ```
13
+
14
+ Run it on a schedule with GitHub Actions, cron, or any Node-capable CI (a GitHub Actions example is below).
15
+
16
+ ## Requirements
17
+
18
+ ### Notion database
19
+
20
+ The database you write daily reports into needs these four properties (names are configurable):
21
+
22
+ | Property | Type | Purpose |
23
+ |---|---|---|
24
+ | One-liner | Title | The entry's title. Posted to Slack as a link |
25
+ | Date | Date | Used to determine which entries to deliver |
26
+ | Team | Multi-select | Used to route entries to the right channel |
27
+ | Author | Created by | Used to display the writer's name |
28
+
29
+ > [!NOTE]
30
+ > The date property should hold **a date only, with no time component**. If a time is present, it may not match Notion's date filter and the entry could be skipped.
31
+
32
+ ### Notion integration
33
+
34
+ 1. Create an integration at [My integrations](https://www.notion.so/my-integrations) and note its API token
35
+ 2. Under the integration's capabilities, **enable "Read user information"** (no email address needed). Without it, the writer's name can't be retrieved and shows as "(unknown)"
36
+ 3. Share (connect) the target database with the integration
37
+ 4. Note the database's **data source ID**
38
+
39
+ ### Slack app
40
+
41
+ 1. Create an app at [Slack API](https://api.slack.com/apps), add the `chat:write` Bot Token Scope, and install it to your workspace
42
+ 2. Note the Bot User OAuth Token (starts with `xoxb-`)
43
+ 3. **Invite the bot user to every channel you deliver to** (it can't post to channels it hasn't been invited to)
44
+
45
+ ## Usage
46
+
47
+ ### CLI
48
+
49
+ ```bash
50
+ # Debug run (logs the message content instead of posting to Slack)
51
+ NOTION_API_TOKEN=xxx npx nippotion --debug
52
+
53
+ # Production run
54
+ NOTION_API_TOKEN=xxx SLACK_BOT_API_TOKEN=xoxb-xxx npx nippotion
55
+ ```
56
+
57
+ | Option | Description |
58
+ |---|---|
59
+ | `-c, --config <path>` | Path to the config file (default: `nippotion.json` in the current directory) |
60
+ | `-d, --debug` | Log the message content instead of posting to Slack |
61
+ | `-h, --help` | Show help |
62
+
63
+ | Environment variable | Description |
64
+ |---|---|
65
+ | `NOTION_API_TOKEN` | Notion integration API token (required) |
66
+ | `SLACK_BOT_API_TOKEN` | Slack Bot User OAuth Token (not needed with `--debug`) |
67
+ | `NIPPOTION_CONFIG` | Path to the config file (same as `--config`; the flag takes precedence) |
68
+ | `NIPPOTION_DEBUG` | Set to `1` for debug mode (same as `--debug`) |
69
+ | `NIPPOTION_LANG` | Language for CLI help and error messages (`ja` / `en`, default: `en`). The config's `language` takes precedence if set |
70
+ | `TZ` | Fallback timezone for date evaluation. Prefer setting `timezone` in the config file |
71
+
72
+ ### Scheduled runs with GitHub Actions
73
+
74
+ Place `nippotion.json` and the following workflow ([examples/notify.yml](./examples/notify.yml)) in your repository, and register `NOTION_API_TOKEN` / `SLACK_BOT_API_TOKEN` as secrets:
75
+
76
+ ```yaml
77
+ name: nippotion
78
+ on:
79
+ schedule:
80
+ - cron: '30 0 * * *' # 9:30 JST
81
+ workflow_dispatch:
82
+ inputs:
83
+ debug_mode:
84
+ description: 'Debug mode (does not post to Slack)'
85
+ type: boolean
86
+ default: false
87
+ jobs:
88
+ notify:
89
+ runs-on: ubuntu-latest
90
+ steps:
91
+ - uses: actions/checkout@v6
92
+ - uses: actions/setup-node@v6
93
+ with:
94
+ node-version: 24
95
+ - run: npx nippotion@1 ${{ github.event.inputs.debug_mode == 'true' && '--debug' || '' }}
96
+ env:
97
+ NOTION_API_TOKEN: ${{ secrets.NOTION_API_TOKEN }}
98
+ SLACK_BOT_API_TOKEN: ${{ secrets.SLACK_BOT_API_TOKEN }}
99
+ ```
100
+
101
+ To verify it works, trigger it manually from the Actions tab with `debug_mode: true`.
102
+
103
+ ## Config reference (nippotion.json)
104
+
105
+ ```json
106
+ {
107
+ "dataSourceId": "The Notion database's data source ID",
108
+ "timezone": "America/New_York",
109
+ "language": "en",
110
+ "holidays": "jp",
111
+ "properties": {
112
+ "title": "Name of the title property",
113
+ "labels": "Name of the multi-select property",
114
+ "author": "Name of the created-by property",
115
+ "date": "Name of the date property"
116
+ },
117
+ "footerText": "Text appended to the end of the message (Slack mrkdwn, optional)",
118
+ "messages": {
119
+ "header": "Here are the entries from {database} for the previous business day",
120
+ "pickup": "*:star: Today's pick goes to {writer}! :star:*",
121
+ "unknownWriter": "(unknown)",
122
+ "notificationText": "Daily reports have arrived"
123
+ },
124
+ "recipients": [
125
+ {
126
+ "labels": ["Entries with any of these labels"],
127
+ "channelId": "are delivered to this channel ID"
128
+ }
129
+ ]
130
+ }
131
+ ```
132
+
133
+ - `channelId` can be found at the end of the Slack channel's link URL, or from the channel details
134
+ - Listing the same label in multiple recipients delivers it to multiple channels
135
+ - `timezone` is the IANA timezone name used to evaluate business days and "the previous business day" (defaults to the runtime's local timezone if omitted)
136
+ - `language` sets the default Slack message wording and the log/error message language (`ja` / `en`, **defaults to `en`**). Set `"language": "ja"` to use it in Japanese
137
+ - Setting `holidays` to a country code also excludes that country's holidays from business days (**weekends only if omitted**; currently only `jp` — Japanese holidays — is supported). Non-business days are not delivered, and are also skipped when computing "the previous business day" (entries from a holiday arrive together on the next business day)
138
+ - `messages` lets you override individual pieces of Slack wording. All fields are optional (defaults follow `language`). `{database}` is replaced with a link to the database, and `{writer}` with the entry's author name
139
+
140
+ ### Example: Japanese setup
141
+
142
+ For a Japanese-language Notion database and Slack workspace, set `language` to `"ja"` and use Japanese property names:
143
+
144
+ ```json
145
+ {
146
+ "dataSourceId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
147
+ "timezone": "Asia/Tokyo",
148
+ "language": "ja",
149
+ "holidays": "jp",
150
+ "properties": {
151
+ "title": "今日のひとこと",
152
+ "labels": "所属",
153
+ "author": "書いた人",
154
+ "date": "日付"
155
+ },
156
+ "footerText": "powered by <https://github.com/kenchan/nippotion|nippotion>",
157
+ "recipients": [
158
+ {
159
+ "labels": ["チームA"],
160
+ "channelId": "C0123456789"
161
+ },
162
+ {
163
+ "labels": ["チームB", "チームC"],
164
+ "channelId": "C9876543210"
165
+ }
166
+ ]
167
+ }
168
+ ```
169
+
170
+ With `"language": "ja"`, the default `messages` wording is Japanese too, so it can be omitted here.
171
+
172
+ ## Development
173
+
174
+ ```bash
175
+ npm ci
176
+ npm start -- --debug # run from source with tsx
177
+ npm run typecheck # type check
178
+ npm run lint # ESLint
179
+ npm test # tests
180
+ npm run build # build dist/ with tsup
181
+ ```
182
+
183
+ ## License
184
+
185
+ [MIT](./LICENSE)
package/dist/cli.js ADDED
@@ -0,0 +1,588 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { parseArgs } from "util";
5
+
6
+ // src/i18n.ts
7
+ import i18next from "i18next";
8
+
9
+ // src/locales/ja.ts
10
+ var ja = {
11
+ cli: {
12
+ help: `\u4F7F\u3044\u65B9: nippotion [options]
13
+
14
+ Notion\u306E\u65E5\u5831\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u304B\u3089\u524D\u55B6\u696D\u65E5\u5206\u3092\u53D6\u5F97\u3057\u3001
15
+ \u30E9\u30D9\u30EB\u306B\u5FDC\u3058\u305FSlack\u30C1\u30E3\u30F3\u30CD\u30EB\u3078\u914D\u4FE1\u3057\u307E\u3059\u3002
16
+
17
+ \u30AA\u30D7\u30B7\u30E7\u30F3:
18
+ -c, --config <path> \u8A2D\u5B9A\u30D5\u30A1\u30A4\u30EB\u306E\u30D1\u30B9\uFF08\u30C7\u30D5\u30A9\u30EB\u30C8: ./nippotion.json\uFF09
19
+ -d, --debug Slack\u306B\u6295\u7A3F\u305B\u305A\u3001\u6295\u7A3F\u5185\u5BB9\u3092\u30ED\u30B0\u306B\u51FA\u529B\u3059\u308B
20
+ -h, --help \u3053\u306E\u30D8\u30EB\u30D7\u3092\u8868\u793A\u3059\u308B
21
+
22
+ \u74B0\u5883\u5909\u6570:
23
+ NOTION_API_TOKEN Notion Integration\u306EAPI\u30C8\u30FC\u30AF\u30F3\uFF08\u5FC5\u9808\uFF09
24
+ SLACK_BOT_API_TOKEN Slack\u306EBot User OAuth Token\uFF08--debug\u6642\u306F\u4E0D\u8981\uFF09
25
+ `
26
+ },
27
+ config: {
28
+ notObject: "\u8A2D\u5B9A\u306F\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059",
29
+ mustBeString: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F\u6587\u5B57\u5217\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\uFF08\u5B9F\u969B\u306E\u578B: {{type}}\uFF09',
30
+ mustBeStringArray: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F\u6587\u5B57\u5217\u306E\u914D\u5217\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059',
31
+ mustBeObject: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059',
32
+ mustBeArray: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F\u914D\u5217\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059',
33
+ invalidTimezone: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u304C\u4E0D\u6B63\u3067\u3059: "{{timezone}}" \u306F\u6709\u52B9\u306AIANA\u30BF\u30A4\u30E0\u30BE\u30FC\u30F3\u540D\u3067\u306F\u3042\u308A\u307E\u305B\u3093\uFF08\u4F8B: Asia/Tokyo\uFF09',
34
+ invalidLanguage: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F "ja" \u307E\u305F\u306F "en" \u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059',
35
+ invalidHolidays: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u304C\u4E0D\u6B63\u3067\u3059: \u5BFE\u5FDC\u3057\u3066\u3044\u308B\u5024\u306F {{supported}} \u3067\u3059',
36
+ readFailed: '\u8A2D\u5B9A\u30D5\u30A1\u30A4\u30EB "{{filePath}}" \u3092\u8AAD\u307F\u8FBC\u3081\u307E\u305B\u3093\u3067\u3057\u305F: {{message}}',
37
+ parseFailed: '\u8A2D\u5B9A\u30D5\u30A1\u30A4\u30EB "{{filePath}}" \u306EJSON\u30D1\u30FC\u30B9\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {{message}}'
38
+ },
39
+ env: {
40
+ missing: "\u5FC5\u9808\u74B0\u5883\u5909\u6570\u304C\u8A2D\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093: {{vars}}"
41
+ },
42
+ notion: {
43
+ propertyNotFound: '\u30D7\u30ED\u30D1\u30C6\u30A3 "{{name}}" \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093'
44
+ },
45
+ slack: {
46
+ header: "\u524D\u55B6\u696D\u65E5\u306E{database}\u306F\u3053\u3061\u3089",
47
+ pickup: "*:star:\u4ECA\u65E5\u306E\u30D4\u30C3\u30AF\u30A2\u30C3\u30D7\u65E5\u8A18\u306F {writer} \u3055\u3093:star:*",
48
+ unknownWriter: "\uFF08\u4E0D\u660E\uFF09",
49
+ notificationText: "\u524D\u55B6\u696D\u65E5\u306E\u65E5\u5831\u304C\u5C4A\u304D\u307E\u3057\u305F"
50
+ }
51
+ };
52
+
53
+ // src/locales/en.ts
54
+ var en = {
55
+ cli: {
56
+ help: `Usage: nippotion [options]
57
+
58
+ Fetches entries for the previous business day from a Notion database
59
+ and delivers them to Slack channels based on their labels.
60
+
61
+ Options:
62
+ -c, --config <path> Path to the config file (default: ./nippotion.json)
63
+ -d, --debug Log the messages instead of posting to Slack
64
+ -h, --help Show this help
65
+
66
+ Environment variables:
67
+ NOTION_API_TOKEN Notion Integration API token (required)
68
+ SLACK_BOT_API_TOKEN Slack Bot User OAuth Token (not needed with --debug)
69
+ `
70
+ },
71
+ config: {
72
+ notObject: "Config must be an object",
73
+ mustBeString: 'Config "{{keyPath}}" must be a string (got: {{type}})',
74
+ mustBeStringArray: 'Config "{{keyPath}}" must be an array of strings',
75
+ mustBeObject: 'Config "{{keyPath}}" must be an object',
76
+ mustBeArray: 'Config "{{keyPath}}" must be an array',
77
+ invalidTimezone: 'Config "{{keyPath}}" is invalid: "{{timezone}}" is not a valid IANA timezone name (e.g. Asia/Tokyo)',
78
+ invalidLanguage: 'Config "{{keyPath}}" must be "ja" or "en"',
79
+ invalidHolidays: 'Config "{{keyPath}}" is invalid: supported values are {{supported}}',
80
+ readFailed: 'Failed to read config file "{{filePath}}": {{message}}',
81
+ parseFailed: 'Failed to parse config file "{{filePath}}" as JSON: {{message}}'
82
+ },
83
+ env: {
84
+ missing: "Required environment variables are not set: {{vars}}"
85
+ },
86
+ notion: {
87
+ propertyNotFound: 'Property "{{name}}" was not found in the data source'
88
+ },
89
+ slack: {
90
+ header: "Here are the entries from {database} for the previous business day",
91
+ pickup: "*:star: Today's pick goes to {writer}! :star:*",
92
+ unknownWriter: "(unknown)",
93
+ notificationText: "Daily reports have arrived"
94
+ }
95
+ };
96
+
97
+ // src/i18n.ts
98
+ void i18next.init({
99
+ lng: process.env.NIPPOTION_LANG ?? "en",
100
+ fallbackLng: "en",
101
+ initAsync: false,
102
+ resources: {
103
+ ja: { translation: ja },
104
+ en: { translation: en }
105
+ },
106
+ interpolation: {
107
+ // Not embedded in HTML, so HTML-escaping of & and quotes is unnecessary
108
+ escapeValue: false
109
+ }
110
+ });
111
+ var t = i18next.t.bind(i18next);
112
+ var i18n_default = i18next;
113
+
114
+ // src/main.ts
115
+ import { Client as Client2 } from "@notionhq/client";
116
+ import { WebClient } from "@slack/web-api";
117
+ import dayjs from "dayjs";
118
+ import utc from "dayjs/plugin/utc.js";
119
+ import timezone from "dayjs/plugin/timezone.js";
120
+
121
+ // src/config.ts
122
+ import { readFileSync } from "fs";
123
+ import { resolve } from "path";
124
+
125
+ // src/workday.ts
126
+ import holidayJp from "@holiday-jp/holiday_jp";
127
+ var HOLIDAY_COUNTRIES = ["jp"];
128
+ var isHoliday = (date, country) => {
129
+ switch (country) {
130
+ case "jp":
131
+ return holidayJp.isHoliday(new Date(date.year(), date.month(), date.date()));
132
+ }
133
+ };
134
+ var isWeekendOrHoliday = (date, holidays) => {
135
+ if (date.day() === 0 || date.day() === 6) return true;
136
+ return holidays !== void 0 && isHoliday(date, holidays);
137
+ };
138
+ var getPreviousWorkday = (date, holidays) => {
139
+ let previousWorkday = date.subtract(1, "day");
140
+ while (isWeekendOrHoliday(previousWorkday, holidays)) {
141
+ previousWorkday = previousWorkday.subtract(1, "day");
142
+ }
143
+ return previousWorkday;
144
+ };
145
+
146
+ // src/config.ts
147
+ var LANGUAGES = ["ja", "en"];
148
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
149
+ var requireString = (value, keyPath) => {
150
+ if (typeof value !== "string") {
151
+ throw new Error(t("config.mustBeString", { keyPath, type: typeof value }));
152
+ }
153
+ return value;
154
+ };
155
+ var requireStringArray = (value, keyPath) => {
156
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
157
+ throw new Error(t("config.mustBeStringArray", { keyPath }));
158
+ }
159
+ return value;
160
+ };
161
+ var validateProperties = (value, keyPath) => {
162
+ if (!isPlainObject(value)) {
163
+ throw new Error(t("config.mustBeObject", { keyPath }));
164
+ }
165
+ return {
166
+ title: requireString(value.title, `${keyPath}.title`),
167
+ labels: requireString(value.labels, `${keyPath}.labels`),
168
+ author: requireString(value.author, `${keyPath}.author`),
169
+ date: requireString(value.date, `${keyPath}.date`)
170
+ };
171
+ };
172
+ var validateRecipient = (value, keyPath) => {
173
+ if (!isPlainObject(value)) {
174
+ throw new Error(t("config.mustBeObject", { keyPath }));
175
+ }
176
+ return {
177
+ labels: requireStringArray(value.labels, `${keyPath}.labels`),
178
+ channelId: requireString(value.channelId, `${keyPath}.channelId`)
179
+ };
180
+ };
181
+ var validateRecipients = (value, keyPath) => {
182
+ if (!Array.isArray(value)) {
183
+ throw new Error(t("config.mustBeArray", { keyPath }));
184
+ }
185
+ return value.map((item, index) => validateRecipient(item, `${keyPath}[${index}]`));
186
+ };
187
+ var validateHolidays = (value, keyPath) => {
188
+ const country = requireString(value, keyPath);
189
+ if (!HOLIDAY_COUNTRIES.includes(country)) {
190
+ throw new Error(t("config.invalidHolidays", { keyPath, supported: HOLIDAY_COUNTRIES.join(", ") }));
191
+ }
192
+ return country;
193
+ };
194
+ var validateLanguage = (value, keyPath) => {
195
+ const language = requireString(value, keyPath);
196
+ if (!LANGUAGES.includes(language)) {
197
+ throw new Error(t("config.invalidLanguage", { keyPath }));
198
+ }
199
+ return language;
200
+ };
201
+ var validateMessages = (value, keyPath, language) => {
202
+ const fixedT = i18n_default.getFixedT(language ?? i18n_default.language);
203
+ const messages = {
204
+ header: fixedT("slack.header"),
205
+ pickup: fixedT("slack.pickup"),
206
+ unknownWriter: fixedT("slack.unknownWriter"),
207
+ notificationText: fixedT("slack.notificationText")
208
+ };
209
+ if (value === void 0) return messages;
210
+ if (!isPlainObject(value)) {
211
+ throw new Error(t("config.mustBeObject", { keyPath }));
212
+ }
213
+ for (const key of Object.keys(messages)) {
214
+ if (value[key] !== void 0) {
215
+ messages[key] = requireString(value[key], `${keyPath}.${key}`);
216
+ }
217
+ }
218
+ return messages;
219
+ };
220
+ var validateTimezone = (value, keyPath) => {
221
+ const tz = requireString(value, keyPath);
222
+ try {
223
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
224
+ } catch {
225
+ throw new Error(t("config.invalidTimezone", { keyPath, timezone: tz }));
226
+ }
227
+ return tz;
228
+ };
229
+ var validateConfig = (data) => {
230
+ if (!isPlainObject(data)) {
231
+ throw new Error(t("config.notObject"));
232
+ }
233
+ const language = data.language === void 0 ? void 0 : validateLanguage(data.language, "language");
234
+ const config = {
235
+ dataSourceId: requireString(data.dataSourceId, "dataSourceId"),
236
+ properties: validateProperties(data.properties, "properties"),
237
+ recipients: validateRecipients(data.recipients, "recipients"),
238
+ messages: validateMessages(data.messages, "messages", language)
239
+ };
240
+ if (language !== void 0) {
241
+ config.language = language;
242
+ }
243
+ if (data.holidays !== void 0) {
244
+ config.holidays = validateHolidays(data.holidays, "holidays");
245
+ }
246
+ if (data.footerText !== void 0) {
247
+ config.footerText = requireString(data.footerText, "footerText");
248
+ }
249
+ if (data.timezone !== void 0) {
250
+ config.timezone = validateTimezone(data.timezone, "timezone");
251
+ }
252
+ return config;
253
+ };
254
+ var loadConfig = (filePath) => {
255
+ let raw;
256
+ try {
257
+ raw = readFileSync(filePath, "utf-8");
258
+ } catch (error) {
259
+ const message = error instanceof Error ? error.message : String(error);
260
+ throw new Error(t("config.readFailed", { filePath, message }));
261
+ }
262
+ let data;
263
+ try {
264
+ data = JSON.parse(raw);
265
+ } catch (error) {
266
+ const message = error instanceof Error ? error.message : String(error);
267
+ throw new Error(t("config.parseFailed", { filePath, message }));
268
+ }
269
+ return validateConfig(data);
270
+ };
271
+ var resolveConfigPath = () => resolve(process.cwd(), process.env.NIPPOTION_CONFIG ?? "nippotion.json");
272
+
273
+ // src/notion.ts
274
+ import { isFullPage, collectPaginatedAPI } from "@notionhq/client";
275
+
276
+ // src/retry.ts
277
+ import pRetry from "p-retry";
278
+ var asRecord = (value) => typeof value === "object" && value !== null ? value : void 0;
279
+ var extractErrorInfo = (error) => {
280
+ const err = asRecord(error);
281
+ const originalError = asRecord(err?.originalError);
282
+ const wrappedError = asRecord(err?.error);
283
+ const cause = asRecord(err?.cause);
284
+ return {
285
+ code: err?.code ?? originalError?.code ?? wrappedError?.code,
286
+ status: err?.status ?? originalError?.status ?? wrappedError?.status,
287
+ causeCode: cause?.code
288
+ };
289
+ };
290
+ var isKnownRetryableCode = (code) => typeof code === "string" && ["ECONNRESET", "ETIMEDOUT", "ENOTFOUND"].includes(code);
291
+ var isRetryableError = (error) => {
292
+ const { code, status, causeCode } = extractErrorInfo(error);
293
+ return code === "notionhq_client_request_timeout" || // Notion rate limiting (429 / rate_limited) resolves by waiting, so retry it
294
+ code === "rate_limited" || status === 429 || code === "notionhq_client_response_error" && (status === void 0 || typeof status === "number" && status >= 500) || isKnownRetryableCode(code) || isKnownRetryableCode(causeCode);
295
+ };
296
+ var formatError = (error) => {
297
+ const message = asRecord(error)?.message;
298
+ if (typeof message === "string" && message) return message;
299
+ if (typeof error === "string") return error;
300
+ return JSON.stringify(error, null, 2);
301
+ };
302
+ var withRetry = (fn) => pRetry(fn, {
303
+ retries: 3,
304
+ minTimeout: 5e3,
305
+ // Aborting via shouldRetry instead of throwing AbortError lets the original error
306
+ // propagate with its structure and stack intact (nothing is lost to stringification)
307
+ shouldRetry: ({ error }) => isRetryableError(error),
308
+ onFailedAttempt: ({ error, attemptNumber, retriesLeft, retryDelay }) => {
309
+ const { code, status } = extractErrorInfo(error);
310
+ const retryable = isRetryableError(error);
311
+ console.log(`Notion API call failed (attempt ${attemptNumber}/${retriesLeft + attemptNumber}):`, {
312
+ code,
313
+ status,
314
+ message: error.message,
315
+ isRetryable: retryable
316
+ });
317
+ if (!retryable) {
318
+ console.error("Non-retryable error encountered:", formatError(error));
319
+ } else if (retriesLeft > 0) {
320
+ console.log(`Retrying in ${retryDelay / 1e3}s...`);
321
+ }
322
+ }
323
+ });
324
+
325
+ // src/notion.ts
326
+ var getDateFilter = (previousWorkday, today, dateProperty) => {
327
+ const previousDay = today.subtract(1, "day");
328
+ if (previousWorkday.isSame(previousDay, "day")) {
329
+ return {
330
+ "property": dateProperty,
331
+ "date": { "equals": previousWorkday.format("YYYY-MM-DD") }
332
+ };
333
+ }
334
+ return {
335
+ "and": [{
336
+ "property": dateProperty,
337
+ "date": {
338
+ "on_or_after": previousWorkday.format("YYYY-MM-DD")
339
+ }
340
+ }, {
341
+ "property": dateProperty,
342
+ "date": {
343
+ "on_or_before": previousDay.format("YYYY-MM-DD")
344
+ }
345
+ }]
346
+ };
347
+ };
348
+ var getDatabaseInfo = async (client, dataSourceId, requiredProperties) => withRetry(async () => {
349
+ const dataSource = await client.dataSources.retrieve({
350
+ data_source_id: dataSourceId
351
+ });
352
+ const propertyIds = requiredProperties.map((name) => {
353
+ const prop = Object.values(dataSource.properties).find((p) => p.name === name);
354
+ if (!prop) throw new Error(t("notion.propertyNotFound", { name }));
355
+ return prop.id;
356
+ });
357
+ return {
358
+ url: dataSource.url,
359
+ title: dataSource.title.map((t2) => t2.plain_text).join(""),
360
+ propertyIds
361
+ };
362
+ });
363
+ var getAllPages = async (client, dataSourceId, filter, filterProperties) => {
364
+ const results = await collectPaginatedAPI(
365
+ (args) => withRetry(() => client.dataSources.query(args)),
366
+ {
367
+ data_source_id: dataSourceId,
368
+ filter,
369
+ filter_properties: filterProperties
370
+ }
371
+ );
372
+ return results.filter(isFullPage);
373
+ };
374
+ var getTitle = (page, titleProperty) => {
375
+ const prop = page.properties[titleProperty];
376
+ if (prop?.type !== "title") return "";
377
+ return prop.title.map((t2) => t2.plain_text).join("");
378
+ };
379
+ var getWriterName = (page, authorProperty) => {
380
+ const prop = page.properties[authorProperty];
381
+ if (prop?.type !== "created_by") return null;
382
+ const user = prop.created_by;
383
+ return "name" in user ? user.name : null;
384
+ };
385
+ var getLabels = (page, labelsProperty) => {
386
+ const prop = page.properties[labelsProperty];
387
+ if (prop?.type !== "multi_select") return [];
388
+ return prop.multi_select.map((s) => s.name);
389
+ };
390
+ var toEntry = (page, properties) => {
391
+ const title = getTitle(page, properties.title);
392
+ if (!title) return null;
393
+ return {
394
+ title,
395
+ writerName: getWriterName(page, properties.author),
396
+ labels: getLabels(page, properties.labels),
397
+ url: page.url
398
+ };
399
+ };
400
+
401
+ // src/slack.ts
402
+ var escapeMrkdwn = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
403
+ var fillTemplate = (template, vars) => template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
404
+ var createHeaderBlock = (databaseUrl, databaseTitle, headerTemplate) => [
405
+ {
406
+ type: "section",
407
+ text: {
408
+ type: "mrkdwn",
409
+ text: fillTemplate(headerTemplate, { database: `<${databaseUrl}|${escapeMrkdwn(databaseTitle)}>` })
410
+ }
411
+ },
412
+ {
413
+ type: "divider"
414
+ }
415
+ ];
416
+ var createFooterBlock = (footerText) => footerText ? [
417
+ {
418
+ type: "divider"
419
+ },
420
+ {
421
+ type: "context",
422
+ elements: [
423
+ {
424
+ type: "mrkdwn",
425
+ text: footerText
426
+ }
427
+ ]
428
+ }
429
+ ] : [];
430
+ var toEntryBlock = (entry, messages, pickup = false) => {
431
+ const title = escapeMrkdwn(entry.title);
432
+ const writerName = escapeMrkdwn(entry.writerName ?? messages.unknownWriter);
433
+ if (pickup) {
434
+ return {
435
+ type: "section",
436
+ text: {
437
+ type: "mrkdwn",
438
+ text: `${fillTemplate(messages.pickup, { writer: writerName })}
439
+ <${entry.url}|${title}>`
440
+ }
441
+ };
442
+ }
443
+ return {
444
+ type: "section",
445
+ text: {
446
+ type: "mrkdwn",
447
+ text: `*${writerName}*
448
+ <${entry.url}|${title}>`
449
+ }
450
+ };
451
+ };
452
+ var MAX_BLOCKS_PER_MESSAGE = 50;
453
+ var buildMessageBlocks = (headerBlock, entriesBlock, pickupBlock, footerBlock) => {
454
+ const singleMessageBlocks = [...headerBlock, ...entriesBlock, ...pickupBlock, ...footerBlock];
455
+ if (singleMessageBlocks.length <= MAX_BLOCKS_PER_MESSAGE) {
456
+ return [singleMessageBlocks];
457
+ }
458
+ const messages = [];
459
+ const maxEntriesInFirstMessage = MAX_BLOCKS_PER_MESSAGE - headerBlock.length;
460
+ const maxEntriesInLastMessage = MAX_BLOCKS_PER_MESSAGE - pickupBlock.length - footerBlock.length;
461
+ messages.push([...headerBlock, ...entriesBlock.slice(0, maxEntriesInFirstMessage)]);
462
+ let rest = entriesBlock.slice(maxEntriesInFirstMessage);
463
+ while (rest.length > maxEntriesInLastMessage) {
464
+ messages.push(rest.slice(0, MAX_BLOCKS_PER_MESSAGE));
465
+ rest = rest.slice(MAX_BLOCKS_PER_MESSAGE);
466
+ }
467
+ messages.push([...rest, ...pickupBlock, ...footerBlock]);
468
+ return messages;
469
+ };
470
+ var notifyNippo = async (ctx, recipient) => {
471
+ const filteredEntries = ctx.entries.filter(
472
+ (entry) => recipient.labels.some((label) => entry.labels.includes(label))
473
+ );
474
+ const entriesBlock = filteredEntries.map((entry) => toEntryBlock(entry, ctx.messages));
475
+ const pickupBlock = ctx.pickup ? [toEntryBlock(ctx.pickup, ctx.messages, true)] : [];
476
+ const messages = buildMessageBlocks(ctx.headerBlock, entriesBlock, pickupBlock, ctx.footerBlock);
477
+ if (ctx.debug) {
478
+ console.log("send to #" + recipient.channelId);
479
+ console.log(JSON.stringify(messages, null, 2));
480
+ return true;
481
+ }
482
+ let succeeded = true;
483
+ for (const blocks of messages) {
484
+ try {
485
+ await ctx.slack.chat.postMessage({
486
+ text: ctx.messages.notificationText,
487
+ blocks,
488
+ channel: recipient.channelId
489
+ });
490
+ } catch (error) {
491
+ console.error(`Failed to send notification to channel: ${recipient.channelId}`, error);
492
+ succeeded = false;
493
+ }
494
+ }
495
+ return succeeded;
496
+ };
497
+
498
+ // src/main.ts
499
+ dayjs.extend(utc);
500
+ dayjs.extend(timezone);
501
+ var checkRequiredEnvVars = (debug) => {
502
+ const missing = [];
503
+ if (!process.env.NOTION_API_TOKEN) missing.push("NOTION_API_TOKEN");
504
+ if (!debug && !process.env.SLACK_BOT_API_TOKEN) missing.push("SLACK_BOT_API_TOKEN");
505
+ if (missing.length > 0) {
506
+ console.error(t("env.missing", { vars: missing.join(", ") }));
507
+ process.exit(1);
508
+ }
509
+ };
510
+ var main = async () => {
511
+ const debug = process.env.NIPPOTION_DEBUG === "1";
512
+ checkRequiredEnvVars(debug);
513
+ let config;
514
+ try {
515
+ config = loadConfig(resolveConfigPath());
516
+ } catch (error) {
517
+ console.error(error instanceof Error ? error.message : String(error));
518
+ process.exit(1);
519
+ }
520
+ if (config.language) await i18n_default.changeLanguage(config.language);
521
+ const today = config.timezone ? dayjs().tz(config.timezone) : dayjs();
522
+ if (isWeekendOrHoliday(today, config.holidays)) return;
523
+ const client = new Client2({
524
+ auth: process.env.NOTION_API_TOKEN,
525
+ timeoutMs: 12e4
526
+ // extended from the 60s default
527
+ });
528
+ const slack = new WebClient(process.env.SLACK_BOT_API_TOKEN);
529
+ const requiredProperties = [config.properties.labels, config.properties.title, config.properties.author];
530
+ const { url, title, propertyIds } = await getDatabaseInfo(client, config.dataSourceId, requiredProperties);
531
+ const previousWorkday = getPreviousWorkday(today, config.holidays);
532
+ const filter = getDateFilter(previousWorkday, today, config.properties.date);
533
+ console.log("nippotion start:", JSON.stringify({
534
+ now: today.toISOString(),
535
+ timezone: config.timezone ?? null,
536
+ today: today.format("YYYY-MM-DD"),
537
+ previousWorkday: previousWorkday.format("YYYY-MM-DD"),
538
+ query: {
539
+ data_source_id: config.dataSourceId,
540
+ filter
541
+ }
542
+ }, null, 2));
543
+ const pages = await getAllPages(client, config.dataSourceId, filter, propertyIds);
544
+ const entries = pages.map((page) => toEntry(page, config.properties)).filter((entry) => entry !== null);
545
+ console.log("entries:", JSON.stringify({ pages: pages.length, entries: entries.length }));
546
+ if (entries.length === 0) return;
547
+ const pickup = entries[Math.floor(Math.random() * entries.length)] ?? null;
548
+ const ctx = {
549
+ slack,
550
+ entries,
551
+ pickup,
552
+ headerBlock: createHeaderBlock(url, title, config.messages.header),
553
+ footerBlock: createFooterBlock(config.footerText),
554
+ messages: config.messages,
555
+ debug
556
+ };
557
+ let hasFailure = false;
558
+ for (const recipient of config.recipients) {
559
+ const succeeded = await notifyNippo(ctx, recipient);
560
+ if (!succeeded) hasFailure = true;
561
+ }
562
+ if (hasFailure) {
563
+ process.exitCode = 1;
564
+ }
565
+ };
566
+
567
+ // src/cli.ts
568
+ var values;
569
+ try {
570
+ ({ values } = parseArgs({
571
+ options: {
572
+ config: { type: "string", short: "c" },
573
+ debug: { type: "boolean", short: "d", default: false },
574
+ help: { type: "boolean", short: "h", default: false }
575
+ }
576
+ }));
577
+ } catch (error) {
578
+ console.error(error instanceof Error ? error.message : String(error));
579
+ console.error(t("cli.help"));
580
+ process.exit(1);
581
+ }
582
+ if (values.help) {
583
+ console.log(t("cli.help"));
584
+ process.exit(0);
585
+ }
586
+ if (values.config) process.env.NIPPOTION_CONFIG = values.config;
587
+ if (values.debug) process.env.NIPPOTION_DEBUG = "1";
588
+ await main();
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "nippotion",
3
+ "version": "0.0.1",
4
+ "description": "Deliver daily reports written in Notion to Slack channels based on their labels",
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "tsx src/cli.ts",
8
+ "typecheck": "tsc --noEmit",
9
+ "lint": "eslint .",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest",
12
+ "build": "tsup",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "author": "Kenichi Takahashi",
16
+ "license": "MIT",
17
+ "dependencies": {
18
+ "@holiday-jp/holiday_jp": "^2.5.1",
19
+ "@notionhq/client": "^5.22.0",
20
+ "@slack/web-api": "^7.17.0",
21
+ "dayjs": "^1.11.21",
22
+ "i18next": "^26.3.6",
23
+ "p-retry": "^8.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@eslint/js": "9.39.5",
27
+ "@types/node": "^24.13.3",
28
+ "eslint": "9.39.5",
29
+ "tsup": "8.5.1",
30
+ "tsx": "^4.22.4",
31
+ "typescript": "^6.0.3",
32
+ "typescript-eslint": "8.65.0",
33
+ "vitest": "^4.1.9"
34
+ },
35
+ "overrides": {
36
+ "brace-expansion@1": "^5.0.8",
37
+ "esbuild": "^0.28.1"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/kenchan/nippotion.git"
42
+ },
43
+ "bin": {
44
+ "nippotion": "dist/cli.js"
45
+ },
46
+ "engines": {
47
+ "node": ">=20"
48
+ },
49
+ "files": [
50
+ "dist"
51
+ ]
52
+ }