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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/dist/cli.js +588 -0
- 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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
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
|
+
}
|