Compare commits

..

388 Commits
1.0.0 ... main

Author SHA1 Message Date
JinU Choi
eccbc94269
Merge pull request #136 from dalbodeule/debug
[hotfix] token claim fixed. (2x)
2025-06-04 16:30:02 +09:00
dalbodeule
77eecaca34
[hotfix] token claim fixed. (2x) 2025-06-04 16:27:32 +09:00
JinU Choi
08576cb35a
Merge pull request #135 from dalbodeule/debug
[hotfix] token claim fixed.
2025-06-04 16:21:02 +09:00
dalbodeule
90230c4691
[hotfix] token claim fixed. 2025-06-04 16:18:02 +09:00
dalbodeule
b2f449bf65
[hotfix] ChzzkClient.refreshAccessToken added. 2025-06-04 15:48:28 +09:00
dalbodeule
5e3a350e15
Merge branch 'develop'
# Conflicts:
#	chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/chzzk/ChzzkHandler.kt
#	chatbot/src/main/kotlin/space/mori/chzzk_bot/chatbot/utils/accessTokenRefresh.kt
2025-06-04 15:46:43 +09:00
dalbodeule
8a0a507e5b
[feature] some logic fixed. 2025-06-04 15:42:31 +09:00
dalbodeule
1c4b818a85
Revert "Merge pull request #133 from dalbodeule/develop"
This reverts commit 83b5eaf34540d854382f0d0f00239529a78380a4, reversing
changes made to a99f3b342a73722d23e0b7328d08f68083b94b9e.
2025-05-27 13:18:52 +09:00
JinU Choi
83b5eaf345
Merge pull request #133 from dalbodeule/develop
[feature] accessToken refresh logic fix.
2025-05-27 13:13:24 +09:00
dalbodeule
b0be81df20
[feature] accessToken refresh logic fix. 2025-05-27 13:11:17 +09:00
JinU Choi
a99f3b342a
Merge pull request #132 from dalbodeule/develop
[feature] manager detect logic fixed.
2025-05-20 11:21:51 +09:00
dalbodeule
a9d3ad436b
[feature] manager detect logic fixed. 2025-05-20 11:17:36 +09:00
JinU Choi
53757476a7
Merge pull request #131 from dalbodeule/debug
[feature] timer debugs. (2x)
2025-05-18 09:36:27 +09:00
dalbodeule
27810c0b7f
[feature] timer debugs. (2x) 2025-05-18 09:31:36 +09:00
JinU Choi
7257100adc
Merge pull request #130 from dalbodeule/develop
[feature] timer debugs.
2025-05-18 09:27:14 +09:00
dalbodeule
f29370a31f
[feature] timer debugs. 2025-05-18 09:24:11 +09:00
JinU Choi
2c0c887ba1
Merge pull request #129 from dalbodeule/develop
[feature] song list websocket service fixed.
2025-05-18 08:56:41 +09:00
dalbodeule
5223cbe2b2
[feature] song list websocket service fixed. 2025-05-18 08:55:01 +09:00
JinU Choi
11f9895198
Merge pull request #128 from dalbodeule/develop
[feature] thumbnail, etc. fixed
2025-05-18 08:17:15 +09:00
dalbodeule
a18b83fcc8
[feature] thumbnail, etc. fixed 2025-05-18 08:14:46 +09:00
JinU Choi
30d5edc5fe
Merge pull request #127 from dalbodeule/develop
[feature] text size limited 100, 100ms delay added.
2025-05-17 14:47:18 +09:00
dalbodeule
0709b8f526
[feature] text size limited 100, 100ms delay added. 2025-05-17 14:37:39 +09:00
dalbodeule
1465716e72
[hotfix] hotfix on register and activate logics. 2025-05-16 00:54:43 +09:00
dalbodeule
d0292e0aa6
[hotfix] hotfix on alert embed tags. 2025-05-16 00:42:28 +09:00
dalbodeule
b2ffd18126
[hotfix] hotfix on lateinit botuid is not initialized 2025-05-16 00:38:26 +09:00
dalbodeule
5fa04a6725
[hotfix] hotfix on manage users. 2025-05-16 00:29:50 +09:00
dalbodeule
f65c446bed
[hotfix] hotfix on some codes. 2025-05-16 00:25:28 +09:00
JinU Choi
729a88a2b3
Merge pull request #126 from dalbodeule/develop
[refactor] user and live stream handling logic
2025-05-16 00:01:07 +09:00
dalbodeule
a896269087
[refactor] user and live stream handling logic
Replaced ChzzkUserCache with event-based user fetching for cleaner architecture. Integrated new ChzzkUserFindEvent and ChzzkUserReceiveEvent to handle user data retrieval. Removed old utility methods and streamlined live stream status checks with updated APIs.
2025-05-15 04:57:17 +09:00
JinU Choi
d92ad1cc51
Merge pull request #125 from dalbodeule/develop
[feature] chzzk api applied.
2025-05-14 15:51:36 +09:00
dalbodeule
8d54d21620
[refactor] some refactor on UserService.setRefreshToken 2025-05-14 15:49:04 +09:00
dalbodeule
101db7d20c
[refactor] chzzk4j update to 0.1.1 2025-05-13 21:31:17 +09:00
dalbodeule
3c3b9a79a2
debug chzzk login 4 2025-05-13 21:25:57 +09:00
dalbodeule
61a5f985c1
Refactor: replace songListScope with appropriate scopes
Replaced `songListScope` with `songScope` in `WSSongRoutes` and `timerScope` in `WSTimerRoutes` to better reflect their respective purposes. Improves code clarity and consistency in scope usage.
2025-04-24 17:48:11 +09:00
dalbodeule
aa95976005
Refactor WebSocket song list handling and improve session logic
Replaced individual WebSocket session management with `SessionHandler` to centralize and streamline logic. Improved code readability, reliability, and maintainability by reducing redundancy and encapsulating session and request handling in dedicated classes. Added retry mechanisms, acknowledgment handling, and better application shutdown handling.
2025-04-24 17:45:25 +09:00
dalbodeule
c5a98943c0
Refactor WebSocket ACK handling and improve message retries
Introduced `waitForAck` to centralize ACK handling logic and updated retry mechanism in `sendWithRetry` to improve reliability and readability. Cleaned up error handling in WebSocket session management and ensured proper cleanup of resources. These changes enhance maintainability and robustness of the WebSocket song list routes.
2025-04-24 17:08:12 +09:00
dalbodeule
8230762053
Refactor WebSocket handlers and add ACK-based message flow
Consolidated coroutine scopes into `songListScope` and `timerScope` for better management across WebSocket routes. Introduced ACK (acknowledgment) handling for reliable message delivery with retries and timeouts. Updated session handling for multiple WebSocket routes to improve code maintainability and consistency.
2025-04-24 16:56:49 +09:00
dalbodeule
d07cdb6ae8
Cancel route scope on application stop and simplify ACK handling.
Added a monitor to cancel the route scope when the application stops, ensuring proper resource cleanup. Removed the timeout logic in the ACK handling method, simplifying the flow while maintaining error handling.
2025-04-24 16:37:11 +09:00
dalbodeule
9c15c8f10d
Remove SongListWebSocketManager and simplify wsSongListRoutes
The SongListWebSocketManager class and its associated logic were removed to streamline the codebase. The wsSongListRoutes function was updated accordingly to no longer require the manager as a parameter.
2025-04-24 16:26:25 +09:00
dalbodeule
5a7f78ff3e
Refactor WebSocket route to use shared CoroutineScope
Introduced a shared `routeScope` with `SupervisorJob` for better coroutine management across WebSocket routes. This replaces ad-hoc CoroutineScope creation, preventing unnecessary scope overhead and supporting centralized cancellation. Mutexes were added for session and song-related operations to ensure thread safety.
2025-04-24 16:23:55 +09:00
dalbodeule
7a84a9e437
Configure SongListWebSocketManager in wsSongListRoutes.
This change adds a `SongListWebSocketManager` instance with a logger to the `wsSongListRoutes` setup. It improves manageability and ensures better logging for WebSocket interactions in the song list route.
2025-04-24 16:01:16 +09:00
dalbodeule
02cede87f8
Add SongListWebSocketManager and refactor WebSocket routes
Introduced SongListWebSocketManager for managing WebSocket sessions, including ping-pong handling and retry mechanisms. Refactored WSSongListRoutes to delegate session management and simplify logic by leveraging the new manager class.
2025-04-24 15:58:56 +09:00
dalbodeule
17d8065a34
Fix session cleanup in WebSocket routes
Add missing `finally` blocks to ensure session removal in WebSocket routes after exceptions. This prevents potential memory leaks and ensures proper resource cleanup.
2025-04-24 15:01:12 +09:00
dalbodeule
0e8462eaf1
Handle WebSocket session removal on channel closure
Add `removeSession` calls in WebSocket exception handling blocks to ensure proper session cleanup when a `ClosedReceiveChannelException` occurs. Prevents potential resource leaks and ensures consistency across WebSocket routes.
2025-04-24 14:56:00 +09:00
dalbodeule
83cb68b63f
**Remove redundant session cleanup in WebSocket error handlers**
Removed unnecessary `removeSession` calls from WebSocket `finally` blocks as they are either handled elsewhere or no longer needed. This simplifies the error handling flow and ensures consistency across WebSocket route implementations.
2025-04-24 14:51:28 +09:00
JinU Choi
c2bb653ee1
Merge pull request #124 from dalbodeule/develop
if account deleted?
2025-03-31 18:47:13 +09:00
dalbodeule
8ab1dc585e
if account deleted? 2025-03-31 18:43:12 +09:00
JinU Choi
22d97c6604
Merge pull request #123 from dalbodeule/debug
debug done
2025-01-09 00:12:01 +09:00
dalbodeule
e653af7853
debug chzzk login 3 2025-01-09 00:04:52 +09:00
JinU Choi
0951169ce1
Merge pull request #122 from dalbodeule/develop
debug chzzk login 2
2025-01-09 00:01:13 +09:00
dalbodeule
9cf85a7bef
debug chzzk login 2 2025-01-08 23:58:11 +09:00
JinU Choi
6bf80c309f
Merge pull request #121 from dalbodeule/develop
debug chzzk login
2025-01-08 23:45:30 +09:00
dalbodeule
2c686e5777
debug chzzk login 2025-01-08 23:42:21 +09:00
JinU Choi
65fae33467
Merge pull request #120 from dalbodeule/develop
login method changed to chzzk login
2025-01-08 23:17:45 +09:00
dalbodeule
d3ed6c2d86
login method changed to chzzk login 2025-01-08 23:13:04 +09:00
JinU Choi
d576d085f7
Merge pull request #119 from dalbodeule/develop
add botIsDisabled, welcomeMessageDisabled.
2024-12-09 14:51:56 +09:00
dalbodeule
eccf1a29bc
add botIsDisabled, welcomeMessageDisabled. 2024-12-09 14:47:41 +09:00
dalbodeule
4fca9df9c2
debugs... 4 2024-11-25 20:04:50 +09:00
dalbodeule
c14fc53dee
debugs... 3 2024-11-25 19:58:17 +09:00
dalbodeule
768eadd561
debugs... 2024-11-25 19:56:22 +09:00
dalbodeule
9396e69570
debugs... 2024-11-25 18:50:23 +09:00
dalbodeule
8dd8f33cdd
dependencies update 2024-11-16 20:35:30 +09:00
dalbodeule
ba1be00390
add one options.
- disableStartupMessage
2024-11-16 20:22:06 +09:00
JinU Choi
99686496b4
Merge pull request #118 from dalbodeule/debug
debug on WSSongListRoutes.kt
2024-09-24 11:28:58 +09:00
dalbodeule
ca2089631a
debug on WSSongListRoutes.kt
- debug re-tx logics
2024-09-24 10:48:50 +09:00
dalbodeule
c9ceaf01fc
debug on WSSongListRoutes.kt
- debug re-tx logics
2024-09-24 10:42:39 +09:00
dalbodeule
94e226fcab
debug on WSSongListRoutes.kt
- add re-tx logics
- add ACK enum value
- else other improve
2024-09-24 10:35:11 +09:00
dalbodeule
3e0246771e
debug on ApiCommandRoutes.kt, SongList.kt
- ApiCommandRoutes.kt is CommandReloadEvent post 4 times.
- SongList.kt is song_list.req_name length 20 to 80(byte)
2024-09-14 18:36:24 +09:00
dalbodeule
c0cec43f39
debug on loading (7x) 2024-08-28 22:16:14 +09:00
dalbodeule
b7985ff72c
debug on loading (6x) 2024-08-28 21:42:46 +09:00
dalbodeule
be057c59e1
debug on loading (5x) 2024-08-28 21:37:25 +09:00
dalbodeule
5450d5039a
debug on loading (4x) 2024-08-28 21:26:24 +09:00
dalbodeule
ae850b93d0
debug on loading (3x) 2024-08-28 21:20:43 +09:00
dalbodeule
5226c5b60d
debug on eagerLoading (2x) 2024-08-28 21:07:49 +09:00
dalbodeule
534aaecb6e
debug on eagerLoading (1x) 2024-08-28 21:01:35 +09:00
dalbodeule
a7069046f6
managers relation is fixed (9x) 2024-08-28 20:39:23 +09:00
dalbodeule
593546c89b
managers relation is fixed (8x) 2024-08-28 17:58:35 +09:00
dalbodeule
f5133c4551
managers relation is fixed (6x) 2024-08-28 17:39:35 +09:00
dalbodeule
7c2f5f90fe
managers relation is fixed (5x) 2024-08-28 17:26:24 +09:00
dalbodeule
3e0f63a3d1
managers relation is fixed (4x) 2024-08-28 17:24:02 +09:00
dalbodeule
5d9b6a7e5e
managers relation is fixed (3x) 2024-08-28 17:16:48 +09:00
dalbodeule
1ec899a55a
managers relation is fixed (2x) 2024-08-28 17:02:22 +09:00
dalbodeule
447858a48c
managers relation is fixed 2024-08-28 16:21:34 +09:00
JinU Choi
7784130be3
Merge pull request #117 from dalbodeule/develop
Manager function to web
2024-08-28 16:01:40 +09:00
dalbodeule
82c4f7b021
managers table is dropped. 2024-08-28 15:58:17 +09:00
dalbodeule
f1dc591b55
some fix on Manager functions. 2024-08-28 15:55:47 +09:00
JinU Choi
bf01af04c6
Merge pull request #116 from dalbodeule/develop
some fix on wsTimerRoutes
2024-08-28 10:46:09 +09:00
dalbodeule
3806b0f824
some fix on wsTimerRoutes 2024-08-28 10:42:41 +09:00
JinU Choi
3cea492563
Merge pull request #115 from dalbodeule/develop
some fix on wsSongListRoutes
2024-08-24 15:13:02 +09:00
dalbodeule
1186f647d2
some fix on songList logics 2024-08-24 15:10:44 +09:00
dalbodeule
b87cf8cbfb
some fix on wsSongListRoutes
- song remove from list(database) logic add
2024-08-24 15:08:05 +09:00
JinU Choi
fc94ffc03c
Merge pull request #114 from dalbodeule/develop
some fix on WSSongListRoutes
2024-08-24 14:34:52 +09:00
dalbodeule
9f4f8e84aa
some fix on WSSongListRoutes
- add delUrl to SongResponse
2024-08-24 14:32:38 +09:00
JinU Choi
36a5b24e4c
Merge pull request #113 from dalbodeule/develop
some fix on SongSResponseDTO
2024-08-24 13:59:13 +09:00
dalbodeule
22bbd05aff
some fix on SongSResponseDTO
- add @Serializable annotation
2024-08-24 13:57:59 +09:00
JinU Choi
ae5e520dea
Merge pull request #112 from dalbodeule/develop
fix SongLists
2024-08-24 13:53:32 +09:00
dalbodeule
8bbd163458
fix some event-emitter 2024-08-24 13:51:08 +09:00
dalbodeule
36d0ed82d6
fix SongLists
- add currentSong object
- change some event, dto.
2024-08-24 13:31:50 +09:00
JinU Choi
8e1e472bda
Merge pull request #111 from dalbodeule/develop
fix discord alert.
2024-08-22 16:33:34 +09:00
dalbodeule
ef983cbe22
fix discord alert.
- Number to Long change.
2024-08-22 16:29:53 +09:00
JinU Choi
91e22107c9
Merge pull request #110 from dalbodeule/develop
fix some endpoints.
2024-08-21 19:10:28 +09:00
dalbodeule
a733f56d8c
fix some endpoints.
- In apiDiscordRoutes, guildId and channelId return string.
- In wsSongListRoutes, retry 5 times, some logic changed.
2024-08-21 19:08:23 +09:00
JinU Choi
e855566193
Merge pull request #109 from dalbodeule/develop
fix wsSongRoutes
2024-08-20 11:31:48 +09:00
dalbodeule
327862f6a5
fix wsSongRoutes
- re-tx logic add
- if re-tx logic fail 3 times, disconnect websocket.
2024-08-20 11:26:55 +09:00
JinU Choi
3972530c79
Merge pull request #108 from dalbodeule/develop
fix wsSongListRoutes
2024-08-19 17:27:29 +09:00
dalbodeule
e3c0266253
fix wsSongListRoutes
- duplicated session is not permited.
- re-tx logic add
2024-08-19 17:24:42 +09:00
JinU Choi
7553e8be7e
Merge pull request #107 from dalbodeule/debug
add "!명령어" commandListCommand.
2024-08-18 18:36:26 +09:00
dalbodeule
764714c33a
add "!명령어" commandListCommand. 2024-08-18 18:34:49 +09:00
JinU Choi
0813955b12
Merge pull request #106 from dalbodeule/develop
command list pages.
2024-08-18 18:28:49 +09:00
dalbodeule
5c3c69c585
command list pages.
- set /commands/{uid} page to not require login.
2024-08-18 18:25:46 +09:00
JinU Choi
4a754569cd
Merge pull request #105 from dalbodeule/develop
chisu playlist fix
2024-08-16 08:46:26 +09:00
dalbodeule
89dda9742a
chisu playlist fix
- if youtube video limited 10-min
- add youtube music regex.
2024-08-16 08:42:53 +09:00
JinU Choi
0d92027acc
Merge pull request #104 from dalbodeule/develop
asdf4
2024-08-15 20:34:39 +09:00
dalbodeule
35d8759ee3
asdf4 2024-08-15 20:32:31 +09:00
JinU Choi
97b29ad76b
Merge pull request #103 from dalbodeule/develop
asdf3
2024-08-15 19:54:22 +09:00
dalbodeule
8a3c64ab46
asdf3 2024-08-15 19:53:40 +09:00
JinU Choi
325860523f
Merge pull request #102 from dalbodeule/develop
asdf2
2024-08-15 19:48:55 +09:00
dalbodeule
1bea333d9d
asdf2 2024-08-15 19:48:31 +09:00
JinU Choi
09ed95a3b3
Merge pull request #101 from dalbodeule/develop
asdf
2024-08-15 19:47:37 +09:00
dalbodeule
c4db370c91
asdf 2024-08-15 19:45:36 +09:00
dalbodeule
6cb915a3e1
chzzkHandler fix (2x) 2024-08-15 19:31:03 +09:00
dalbodeule
23c28ce643
chzzkHandler fix 2024-08-15 19:26:26 +09:00
JinU Choi
032a03b3ce
Merge pull request #100 from dalbodeule/develop
error handler errorlog add.
2024-08-15 19:19:04 +09:00
dalbodeule
6ba1d1bca9
error handler errorlog add. 2024-08-15 19:18:30 +09:00
JinU Choi
599a532d81
Merge pull request #99 from dalbodeule/develop
chzzkHandler fix
2024-08-15 17:37:40 +09:00
dalbodeule
a3b69acc18
chzzkHandler fix 2024-08-15 17:35:45 +09:00
JinU Choi
b889599558
Merge pull request #98 from dalbodeule/develop
command, ws ping message fix
2024-08-15 16:58:12 +09:00
dalbodeule
20417745d4
command, ws ping message fix
- remove commands (command CRUD, hook, alert)
- ws ping message fix
2024-08-15 16:56:31 +09:00
JinU Choi
ec0d811dbc
Merge pull request #97 from dalbodeule/develop
apiDiscordRoutes add.
2024-08-15 13:50:47 +09:00
dalbodeule
9a303ff342
apiDiscordRoutes add.
- add POST /discord/{uid} endpoints.
2024-08-15 13:49:18 +09:00
JinU Choi
3fe36daf14
Merge pull request #96 from dalbodeule/develop
apiDiscordRoutes fix. (18x)
2024-08-15 11:23:36 +09:00
dalbodeule
a56f504418
apiDiscordRoutes fix. (18x)
- delete some debugging codes.
2024-08-15 11:21:55 +09:00
JinU Choi
b2f4549e7f
Merge pull request #95 from dalbodeule/develop
apiDiscordRoutes fix. (17x)
2024-08-15 11:18:00 +09:00
dalbodeule
15d6f3d9b1
apiDiscordRoutes fix. (17x)
- wrong type definition(GuildChannel.type) fix
2024-08-15 11:13:44 +09:00
JinU Choi
6e4770a8de
Merge pull request #94 from dalbodeule/develop
apiDiscordRoutes fix. (16x)
2024-08-15 11:08:59 +09:00
dalbodeule
f85cbb8111
apiDiscordRoutes fix. (16x)
- wrong type definition(GuildRoles.color) fix
2024-08-15 11:07:34 +09:00
JinU Choi
4945f4cfbe
Merge pull request #93 from dalbodeule/develop
apiDiscordRoutes fix. (15x)
2024-08-15 11:03:04 +09:00
dalbodeule
34376ab720
apiDiscordRoutes fix. (15x)
- some logic changed.
2024-08-15 11:01:21 +09:00
JinU Choi
8903b7660d
Merge pull request #92 from dalbodeule/develop
apiDiscordRoutes fix. (14x)
2024-08-15 10:53:53 +09:00
dalbodeule
c38af5a511
apiDiscordRoutes fix. (14x)
- some logic changed.
2024-08-15 10:52:06 +09:00
dalbodeule
5fbf47507c
apiDiscordRoutes fix. (13x)
- some logic changed.
2024-08-15 10:49:45 +09:00
JinU Choi
68c76da9c7
Merge pull request #91 from dalbodeule/develop
apiDiscordRoutes fix. (11x)
2024-08-15 10:41:59 +09:00
dalbodeule
c0e65ad771
apiDiscordRoutes fix. (11x)
- some logic changed.
2024-08-15 10:40:27 +09:00
JinU Choi
46e084d258
Merge pull request #90 from dalbodeule/develop
apiDiscordRoutes fix. (10x)
2024-08-15 10:36:56 +09:00
dalbodeule
355e07bf21
apiDiscordRoutes fix. (10x)
- some logic changed.
2024-08-15 10:34:27 +09:00
JinU Choi
090cb8ade2
Merge pull request #89 from dalbodeule/develop
apiDiscordRoutes fix. (9x)
2024-08-15 10:31:53 +09:00
dalbodeule
1ac716cc06
apiDiscordRoutes fix. (9x)
- some logic changed.
2024-08-15 10:30:15 +09:00
JinU Choi
380e36188f
Merge pull request #88 from dalbodeule/develop
apiDiscordRoutes fix. (7x)
2024-08-15 10:25:37 +09:00
dalbodeule
f16e3658ea
apiDiscordRoutes fix. (7x)
- some logic changed.
2024-08-15 10:24:19 +09:00
JinU Choi
bea6905b7b
Merge pull request #87 from dalbodeule/develop
apiDiscordRoutes fix. (6x)
2024-08-15 10:18:52 +09:00
dalbodeule
67b4209d13
apiDiscordRoutes fix. (6x)
- add debug code
2024-08-15 10:17:20 +09:00
JinU Choi
63318a9ab6
Merge pull request #86 from dalbodeule/develop
apiDiscordRoutes fix. (5x)
2024-08-15 10:12:22 +09:00
dalbodeule
a9c4398be3
apiDiscordRoutes fix. (5x)
- add debug code
2024-08-15 10:10:45 +09:00
JinU Choi
54905803bb
Merge pull request #85 from dalbodeule/develop
apiDiscordRoutes fix. (4x)
2024-08-15 07:39:17 +09:00
dalbodeule
e7bdb360fc
apiDiscordRoutes fix. (4x)
- fix server insights.
2024-08-15 07:29:27 +09:00
JinU Choi
c7ddefa38d
Merge pull request #84 from dalbodeule/develop
apiDiscordRoutes fix. (3x)
2024-08-15 07:24:24 +09:00
dalbodeule
3285341b9c
apiDiscordRoutes fix. (3x)
- fix server insights.
2024-08-15 07:22:36 +09:00
JinU Choi
3d2e9a8cb0
Merge pull request #83 from dalbodeule/develop
apiDiscordRoutes fix.
2024-08-15 07:16:09 +09:00
dalbodeule
456e447a5e
apiDiscordRoutes fix. (2x)
- add guidChannels.
2024-08-15 07:14:35 +09:00
dalbodeule
1a3282d46c
apiDiscordRoutes fix.
- add guidChannels.
2024-08-15 07:13:37 +09:00
JinU Choi
568c3638c6
Merge pull request #82 from dalbodeule/develop
apiDiscordRoutes fix.
2024-08-15 07:04:09 +09:00
dalbodeule
c87eeaa9e8
apiDiscordRoutes fix.
- add guildRole appends.
2024-08-15 07:02:39 +09:00
JinU Choi
58fd4684b8
Merge pull request #81 from dalbodeule/develop
apiDiscordRoutes fix.
2024-08-15 06:54:06 +09:00
dalbodeule
91422aefce
apiDiscordRoutes fix.
- explicitNulls = false
2024-08-15 06:52:36 +09:00
JinU Choi
85e253af1a
Merge pull request #80 from dalbodeule/develop
apiDiscordRoutes fix.
2024-08-15 06:49:23 +09:00
dalbodeule
d0267ec50a
apiDiscordRoutes fix.
- roles is missing, to emptyList()
2024-08-15 06:47:56 +09:00
JinU Choi
84d7349c98
Merge pull request #79 from dalbodeule/develop
apiDiscordRoutes fix.
2024-08-15 06:42:04 +09:00
dalbodeule
d90790f742
apiDiscordRoutes fix.
- roles is nullable.
2024-08-15 06:40:38 +09:00
JinU Choi
595df4ecaa
Merge pull request #78 from dalbodeule/develop
apiDiscordRoutes fix.
2024-08-15 06:37:08 +09:00
dalbodeule
3572a5c5db
apiDiscordRoutes fix.
- add respond to /discord endpoints.
- fix /guild/{gid} endpoints.
2024-08-15 06:33:52 +09:00
JinU Choi
62449959a5
Merge pull request #77 from dalbodeule/develop
Discord guild related bug fix, improve
2024-08-15 06:15:25 +09:00
dalbodeule
85d728df8a
get bot's guilds. 2024-08-15 06:11:48 +09:00
dalbodeule
ce9be7e47f
get guild roles with getGuilds. 2024-08-15 06:08:22 +09:00
JinU Choi
5fb25089d5
Merge pull request #76 from dalbodeule/develop
debug some codes...
2024-08-14 22:17:58 +09:00
dalbodeule
66c1252312
debug some codes... 2024-08-14 22:15:36 +09:00
JinU Choi
5ff78f4532
Merge pull request #75 from dalbodeule/develop
SongType.NEXT to songList.isEmpty() true/false.
2024-08-14 22:10:33 +09:00
dalbodeule
c2e546da88
SongType.NEXT to songList.isEmpty() true/false. 2024-08-14 22:08:58 +09:00
dalbodeule
5498135257
asdf16 2024-08-14 21:23:53 +09:00
dalbodeule
7d12985801
asdf15 2024-08-14 21:18:40 +09:00
dalbodeule
ac2cd3725c
asdf14 2024-08-14 21:13:13 +09:00
dalbodeule
f13412d77a
asdf13 2024-08-14 20:50:58 +09:00
dalbodeule
ab32fcc7dc
asdf12 2024-08-14 20:40:20 +09:00
dalbodeule
0e3264a9f3
asdf11 2024-08-14 20:28:17 +09:00
dalbodeule
cb5bbf73c3
asdf10 2024-08-14 20:20:50 +09:00
dalbodeule
1e1cd50e52
asdf9 2024-08-14 20:18:06 +09:00
dalbodeule
370e4519c2
asdf8 2024-08-14 20:13:47 +09:00
dalbodeule
c6a1c229f7
asdf7 2024-08-14 20:10:50 +09:00
dalbodeule
1a0067cca9
asdf6 2024-08-14 20:05:18 +09:00
dalbodeule
a7628f0a7e
asdf5 2024-08-14 20:02:06 +09:00
dalbodeule
b3ba7767f6
asdf4 2024-08-14 19:54:32 +09:00
dalbodeule
1679d8cd18
asdf3 2024-08-14 19:39:47 +09:00
dalbodeule
4d0345be9e
asdf2 2024-08-14 19:32:30 +09:00
dalbodeule
40f1df7ba6
asdf 2024-08-14 19:28:14 +09:00
JinU Choi
9ab004d646
Merge pull request #74 from dalbodeule/develop
avoid discord limits.
2024-08-14 19:20:20 +09:00
dalbodeule
dd8e5065df
avoid discord limits.
- fix DiscordRatelimits
2024-08-14 19:18:50 +09:00
JinU Choi
ce24c6b0c5
Merge pull request #73 from dalbodeule/develop
avoid discord limits.
2024-08-14 19:14:03 +09:00
dalbodeule
7a9eccd293
avoid discord limits.
- fix DiscordRatelimits
2024-08-14 19:12:05 +09:00
JinU Choi
12d3396630
Merge pull request #72 from dalbodeule/develop
avoid discord limits.
2024-08-14 18:49:14 +09:00
dalbodeule
bdf1420455
avoid discord limits.
- fix DiscordRatelimits
2024-08-14 18:46:57 +09:00
JinU Choi
61b27ade29
Merge pull request #71 from dalbodeule/develop
avoid discord limits.
2024-08-14 18:13:41 +09:00
dalbodeule
f958912d43
avoid discord limits.
- add DiscordRatelimits
2024-08-14 18:12:00 +09:00
JinU Choi
9f15e8f9fb
Merge pull request #70 from dalbodeule/develop
avoid discord limits.
2024-08-14 17:57:21 +09:00
dalbodeule
dfc84452af
avoid discord limits. 2024-08-14 17:54:49 +09:00
JinU Choi
3ab6a4031a
Merge pull request #69 from dalbodeule/develop
debug DiscordGuildCache (x2)
2024-08-14 17:37:46 +09:00
dalbodeule
0e5b99bfb9
debug DiscordGuildCache (x2)
- avoid ratelimit.
- cache logic fix
- on login, add Cache
2024-08-14 17:35:54 +09:00
JinU Choi
b241a0cf73
Merge pull request #68 from dalbodeule/develop
debug DiscordGuildCache (x2)
2024-08-14 17:13:32 +09:00
dalbodeule
4c8c6daae1
debug DiscordGuildCache (x2)
- avoid ratelimit.
2024-08-14 17:10:54 +09:00
JinU Choi
74709a076d
Merge pull request #67 from dalbodeule/develop
debug DiscordGuildCache
2024-08-14 17:08:23 +09:00
dalbodeule
8244dbebfa
debug DiscordGuildCache
- avoid ratelimit.
2024-08-14 17:07:04 +09:00
JinU Choi
8aab3aa15b
Merge pull request #66 from dalbodeule/develop
Develop
2024-08-14 17:00:03 +09:00
dalbodeule
c1553fd47a
add DiscordGuildCache
- GET /discord endpoint, return guild information.
- GET /discord/{uid} to return null(404)
2024-08-14 16:56:25 +09:00
dalbodeule
167278e5cf
add DiscordGuildCache
- add DiscordGuildCache
- GET /discord endpoint, return guild information.
2024-08-14 10:44:15 +09:00
JinU Choi
88c84c4610
Merge pull request #65 from dalbodeule/develop
debug some errors.
2024-08-14 05:59:00 +09:00
dalbodeule
07af664b0d
debug some errors.
- ChzzkHandler.runStreamInfo debugs with startThread functions.
2024-08-14 05:57:30 +09:00
JinU Choi
1d5b726a89
Merge pull request #64 from dalbodeule/develop
debug some errors.
2024-08-14 05:52:38 +09:00
dalbodeule
4f093291f4
debug some errors.
- ChzzkHandler.runStreamInfo debugs with startThread functions.
2024-08-14 05:50:58 +09:00
JinU Choi
b4d1f59df1
Merge pull request #63 from dalbodeule/develop
debug some errors.
2024-08-14 05:46:57 +09:00
dalbodeule
b643ed83e7
debug some errors.
- ChzzkHandler.runStreamInfo debugs with startThread functions.
2024-08-14 05:45:15 +09:00
JinU Choi
35074d0aac
Merge pull request #62 from dalbodeule/develop
debug some errors.
2024-08-13 20:28:57 +09:00
dalbodeule
b6a68fe35a
debug some errors.
- ChzzkHandler.runStreamInfo debugs with startThread functions.
2024-08-13 20:25:49 +09:00
JinU Choi
d8c27b3238
Merge pull request #61 from dalbodeule/develop
debug some errors.
2024-08-13 19:23:21 +09:00
dalbodeule
910886f8b5
debug some errors.
- ChzzkHandler.runStreamInfo debugs with startThread functions.
2024-08-13 19:20:16 +09:00
JinU Choi
4c15aac295
Merge pull request #60 from dalbodeule/develop
debug some errors.
2024-08-13 07:18:05 +09:00
dalbodeule
f994972fac
debug some errors.
- wsSongListRoutes to isNotEmpty(), access list[0]
- ChzzkHandler.runStreamInfo debugs with startThread functions.
2024-08-13 07:07:40 +09:00
dalbodeule
355b56a6cf
debug discord login (16x)
- remove unless println
- DONE
2024-08-11 17:09:02 +09:00
dalbodeule
27ee9b811c
debug discord login (15x)
- session add owned server.
2024-08-11 17:07:42 +09:00
dalbodeule
c49ba73ec4
debug discord login (14x)
- DiscordGuildLIstAPI fix
2024-08-11 17:03:40 +09:00
dalbodeule
c05521d23f
debug discord login (13x)
- DiscordGuildLIstAPI fix
2024-08-11 17:00:24 +09:00
dalbodeule
f407310348
debug discord login (12x)
- DiscordGuildLIstAPI fix
2024-08-11 16:55:38 +09:00
dalbodeule
15b40bbc54
debug discord login (11x)
- getUserGuilds fix (2x)
2024-08-11 16:52:35 +09:00
dalbodeule
18ad1c8b83
debug discord login (10x)
- getUserGuilds fix
2024-08-11 16:48:43 +09:00
dalbodeule
7d74fb87c5
debug discord login (9x)
- add identify scopes
2024-08-11 16:44:00 +09:00
dalbodeule
e68794ce33
debug discord login (8x) 2024-08-11 16:39:35 +09:00
dalbodeule
6ccbb4f3f7
debug discord login (7x) 2024-08-11 16:34:50 +09:00
dalbodeule
a1e44c3bb1
debug discord login (6x) 2024-08-11 16:28:26 +09:00
dalbodeule
25b835456e
debug discord login (5x) 2024-08-11 16:02:51 +09:00
dalbodeule
299ecd2943
debug discord login (4x) 2024-08-11 16:00:03 +09:00
dalbodeule
dc7e05781f
debug discord login (3x) 2024-08-11 15:56:47 +09:00
dalbodeule
b38080111c
debug discord login (2x) 2024-08-11 15:52:36 +09:00
dalbodeule
eab30ff5c3
debug discord login 2024-08-11 15:48:01 +09:00
JinU Choi
977ca3e69f
Merge pull request #59 from dalbodeule/develop
add discord login
2024-08-11 15:42:26 +09:00
dalbodeule
9c047d3d87
add discord login
- add discord login logics
- add discord api classes, functions.
- add current user's discord guilds. (if session has discordGuilds, return it else return null lists)
2024-08-11 15:39:38 +09:00
JinU Choi
2293596459
Merge pull request #58 from dalbodeule/develop
fix somes.
2024-08-10 21:54:19 +09:00
dalbodeule
dd628738f7
fix somes.
- command renamed "!노래추가" to "!신청곡", and its debugged.
- command "!노래시작" to user.discordId is null, send announce to chzzk chat.
- songlist function isDisabled value added.
2024-08-10 21:50:58 +09:00
JinU Choi
7e417b5d12
Merge pull request #57 from dalbodeule/develop
fix HookCommand, fix documentation
2024-08-10 18:06:24 +09:00
dalbodeule
ac7f9b3ad0
fix HookCommand, fix documentation
- fix HookCommand (.queue() typo)
- fix hook command.
2024-08-10 18:04:02 +09:00
JinU Choi
953e451597
Merge pull request #56 from dalbodeule/develop
fix ApiDiscordRoutes.kt
2024-08-10 17:36:46 +09:00
dalbodeule
0acccce720
fix ApiDiscordRoutes.kt
- add @Serializable annotation of DTO class
2024-08-10 17:35:18 +09:00
JinU Choi
42ac32b303
Merge pull request #55 from dalbodeule/develop
debug session (2x)
2024-08-10 17:30:59 +09:00
dalbodeule
a26586c6b4
debug session (2x)
- MariadbSessionStorage not found error fix.
2024-08-10 17:29:40 +09:00
JinU Choi
eab0107115
Merge pull request #54 from dalbodeule/develop
debug session
2024-08-10 17:27:18 +09:00
dalbodeule
a01827b055
debug session
- MariadbSessionStorage not found error fix.
2024-08-10 17:25:52 +09:00
JinU Choi
6f9fe9ee21
Merge pull request #53 from dalbodeule/develop
debug discordHook logics, session fix.
2024-08-10 17:19:35 +09:00
dalbodeule
2335a09391
debug discordHook logics, session fix.
- DiscordHook normal respond is not defined
- add MariadbSessionStorage
2024-08-10 17:16:36 +09:00
JinU Choi
ba028a01a5
Merge pull request #52 from dalbodeule/develop
add and debug discord hook logics
2024-08-10 16:57:42 +09:00
dalbodeule
e951ccee56
debug discordHook logics 2024-08-10 16:55:48 +09:00
dalbodeule
2d0e83efc2
add discord hook logics
- add ApiDiscordRoutes.kt
- modify HookCommand from RegisterCommand.kt
2024-08-10 16:54:32 +09:00
dalbodeule
6d7a6beb10
debug Chzzk-Streaminfo.
- add re-run code.
2024-08-10 13:15:16 +09:00
dalbodeule
b12e29674a
debug some codes. 2024-08-10 00:39:23 +09:00
dalbodeule
9a91537e59
debug WSSongListRoutes.kt
- session deleted.
2024-08-09 19:17:27 +09:00
JinU Choi
18d28262d1
Merge pull request #51 from dalbodeule/develop
update naverMEAPI
2024-08-09 15:03:33 +09:00
dalbodeule
9110917e50
update naverMEAPI
- remove nickname, profileImage args.
2024-08-09 15:00:00 +09:00
JinU Choi
fd281b8271
Merge pull request #50 from dalbodeule/develop
Develop
2024-08-09 13:43:10 +09:00
dalbodeule
1b49780cae
add ApiTimerRoutes endpoint 2024-08-09 13:40:52 +09:00
dalbodeule
94b87dc87e
add getUserInfo function 2024-08-09 13:11:09 +09:00
JinU Choi
9e30ca1451
Merge pull request #49 from dalbodeule/develop
add ApiCommandRoutes.kt
2024-08-09 12:39:43 +09:00
dalbodeule
80dbf10e7e
add ApiCommandRoutes.kt
- add GET/PUT/POST/DELETE endpoints.
2024-08-09 11:36:47 +09:00
dalbodeule
4c3300203b
fix User.kt done 2024-08-08 21:08:29 +09:00
dalbodeule
042afde5c7
fix User.kt(7x)
- fun enable fix
2024-08-08 21:02:30 +09:00
dalbodeule
9dd1d1804a
fix User.kt(6x)
- fun enable fix
2024-08-08 20:49:52 +09:00
dalbodeule
60aedc6954
fix User.kt(5x)
- fun enable fix
2024-08-08 20:46:10 +09:00
dalbodeule
e342a3aabb
fix User.kt(4x)
- fun enable fix
2024-08-08 20:41:17 +09:00
dalbodeule
81c62d6c55
fix User.kt(3x)
- fun enable fix
2024-08-08 20:33:27 +09:00
dalbodeule
cbe1c64411
fix User.kt(2x)
- fun enable fix
2024-08-08 20:29:07 +09:00
dalbodeule
a6cd5ba8b5
fix User.kt
- fun enable fix
2024-08-08 20:26:02 +09:00
dalbodeule
0f175ab045
fix User.kt
- set "discord", "token" row to nullable.
2024-08-08 20:23:03 +09:00
dalbodeule
f2a871d664
fix User.kt
- set "discord", "token" row to nullable.
2024-08-08 20:18:49 +09:00
dalbodeule
80fa2d5029
add POST /user endpoints.
- POST /user {"chzzkUrl": String} to add chzzk account to bots.
- and it post event UserRegisterEvent
2024-08-08 19:36:58 +09:00
dalbodeule
d0840ad6a7
modify RegisterCommand
- register page is created.
2024-08-08 17:52:20 +09:00
dalbodeule
6b60e2c698
add Session flows (2x)
- fix APIRoutes (debug Naver UserID)
2024-08-08 17:39:43 +09:00
dalbodeule
fb3470ae83
add Session flows (1x)
- fix APIRoutes (debug Naver UserID)
2024-08-08 17:38:15 +09:00
dalbodeule
172885ae38
add Session flows (1x)
- fix APIRoutes (debug Naver UserID)
2024-08-08 17:37:01 +09:00
dalbodeule
561ab188b2
add Session flows (1x)
- fix APIRoutes
2024-08-08 17:30:42 +09:00
dalbodeule
369d5491e8
add Session flows (5x)
- fix CORS
2024-08-08 17:26:33 +09:00
dalbodeule
a2b2608312
add Session flows (4x)
- fix CORS
2024-08-08 17:13:52 +09:00
dalbodeule
8e6a8a106b
add Session flows (4x)
- fix CORS
2024-08-08 17:10:52 +09:00
dalbodeule
cd2b4e0fc0
add Session flows (3x)
- fix CORS
2024-08-08 17:08:01 +09:00
dalbodeule
9879fafa25
add Session flows (2x)
- fix CORS
2024-08-08 17:06:20 +09:00
dalbodeule
caf3965334
add Session flows
- fix CORS
2024-08-08 16:43:39 +09:00
dalbodeule
6a7f57ac34
Merge remote-tracking branch 'origin/main' 2024-08-08 16:30:09 +09:00
dalbodeule
e18571778a
add Session flows
- fix CORS
2024-08-08 16:29:59 +09:00
JinU Choi
70ce830c94
Merge pull request #48 from dalbodeule/develop
add Session flows
2024-08-08 16:23:45 +09:00
dalbodeule
c179195b18
add Session flows
- fix CORS
2024-08-08 16:22:06 +09:00
JinU Choi
54add09e79
Merge pull request #47 from dalbodeule/develop
add Session flows
2024-08-08 15:51:56 +09:00
dalbodeule
b864fc262b
add Session flows
- WSSongListRoutes
- fix CORS
2024-08-08 15:47:40 +09:00
JinU Choi
e9d2ee26d6
Merge pull request #46 from dalbodeule/develop
add Session flows
2024-08-08 14:56:19 +09:00
dalbodeule
8dde66c069
add Session flows
- songlist page to changed.
2024-08-08 14:53:37 +09:00
dalbodeule
a828e23767
add OAuth flows
- add naver OAuth flow
2024-08-08 14:30:26 +09:00
JinU Choi
2f5c4293c0
Merge pull request #45 from dalbodeule/develop
RegisterCommand some fix
2024-08-08 10:12:49 +09:00
dalbodeule
0317c80ccf
RegisterCommand some fix
- add regex match logics. it can to register with chzzk channel/studio URL
2024-08-08 09:42:37 +09:00
JinU Choi
8d50d1c3aa
Merge pull request #44 from dalbodeule/develop
some logic changed.
2024-08-07 13:18:05 +09:00
dalbodeule
de30e632e5
some logic changed. 2024-08-07 13:12:38 +09:00
JinU Choi
5d0fe98c66
Merge pull request #43 from dalbodeule/develop
add try-catch in stream on discords.
2024-08-07 08:59:19 +09:00
dalbodeule
608ba49a71
add try-catch in stream on discords. 2024-08-07 08:55:50 +09:00
dalbodeule
5a46e62a61
some fix - final
- debug searchYoutube fun (8x)
2024-08-06 21:12:44 +09:00
dalbodeule
833bfbd46d
some fix
- debug searchYoutube fun (7x)
2024-08-06 21:09:30 +09:00
dalbodeule
76ffebc157
some fix
- debug searchYoutube fun (6x)
2024-08-06 21:05:39 +09:00
dalbodeule
e8bee6ff23
some fix
- debug searchYoutube fun (5x)
2024-08-06 20:57:21 +09:00
dalbodeule
60523f992b
some fix
- debug searchYoutube fun (4x)
2024-08-06 20:52:42 +09:00
dalbodeule
a10579ea6b
some fix
- debug searchYoutube fun (3x)
2024-08-06 20:49:47 +09:00
dalbodeule
5dcbbdeb80
some fix
- debug searchYoutube fun (2x)
2024-08-06 20:44:46 +09:00
dalbodeule
bedd5406a0
some fix
- debug searchYoutube fun
2024-08-06 20:37:34 +09:00
dalbodeule
a50a3f21a3
some fix
- (tmp add) logger.info
2024-08-06 20:29:41 +09:00
JinU Choi
c9ad739d05
Merge pull request #42 from dalbodeule/develop
some fix
2024-08-06 20:17:33 +09:00
dalbodeule
d329b8bdb3
some fix
- add searchYoutube function.
- add try-catch blocks.
2024-08-06 20:14:34 +09:00
JinU Choi
816c82e57b
Merge pull request #41 from dalbodeule/develop
some fix
2024-08-05 21:19:59 +09:00
dalbodeule
debbd61aa7
some fix
- "!노래추가" command append limits.
2024-08-05 21:16:41 +09:00
JinU Choi
3e3a283a16
Merge pull request #40 from dalbodeule/develop
some fix
2024-08-05 20:59:30 +09:00
dalbodeule
c5f8ce7528
some fix
- "!명령어추가", "!명령어수정", "!명령어삭제" command fix. (is able reload)
- on /session/{sid} endpoint, add streamer configs
2024-08-05 20:55:18 +09:00
JinU Choi
7df2b68a5f
Merge pull request #39 from dalbodeule/develop
some fix
2024-08-05 20:46:52 +09:00
dalbodeule
778fe8df34
some fix
- !노래시작 command URL typo.
- SongEvent(SongType.REMOVE) debug in WSSongListRoutes
2024-08-05 20:45:34 +09:00
JinU Choi
4c5fa5742f
Merge pull request #38 from dalbodeule/develop
some fix WSSongListRoutes.kt
2024-08-05 18:33:48 +09:00
dalbodeule
4f589780b8
some fix WSSongListRoutes.kt
- add url parameters
2024-08-05 18:06:46 +09:00
JinU Choi
d5dc7a61c9
Merge pull request #37 from dalbodeule/develop
some fix WSSongListRoutes.kt
2024-08-05 16:25:46 +09:00
dalbodeule
9b046d38b9
some fix WSSongListRoutes.kt
- add PacketType Handler.
- add SongType.NEXT handler.
2024-08-05 16:23:46 +09:00
JinU Choi
8e2c0243bb
Merge pull request #36 from dalbodeule/develop
some fix WSSongListRoutes.kt
2024-08-05 15:02:11 +09:00
dalbodeule
75da1c1576
some fix WSSongListRoutes.kt
- delete websocket key
- else updated.
2024-08-05 14:38:16 +09:00
JinU Choi
fdd8eeda23
Merge pull request #35 from dalbodeule/develop
some improve WSApis
2024-08-05 13:30:07 +09:00
dalbodeule
cc23ac03e7
some improve WSApis
- with WSSongListRoutes.kt, data receive logics.
- else WSAPI's logger name changed.
- MessageHandler.kt "!노래시작" command improved.
2024-08-05 13:25:28 +09:00
JinU Choi
240503a4d5
Merge pull request #34 from dalbodeule/develop
add WSSongListRoutes.kt
2024-08-05 12:43:58 +09:00
dalbodeule
0a4e8193bb
add WSSongListRoutes.kt
- add Websocket backend.
- add session start command.
- some improve logics.
2024-08-05 12:35:11 +09:00
JinU Choi
59b90f2bcf
Merge pull request #33 from dalbodeule/develop
Add command, update document.
2024-08-04 21:57:28 +09:00
dalbodeule
47228394d5
Add command, update document.
- add !노래목록 command
- Document add (timer, playlist commands)
2024-08-04 21:54:55 +09:00
JinU Choi
04f6b14daa
Merge pull request #32 from dalbodeule/develop
Fix /user/{uid} endoints.
2024-08-04 20:32:40 +09:00
dalbodeule
6da0662e2a
Fix /user/{uid} endoints.
- ChzzkApis.kt moved to common
- ChzzkApis.kt response data is nullable data.
- if require {uid}'s data, getStreamInfo function called.
2024-08-04 20:27:54 +09:00
JinU Choi
5c93476c34
Merge pull request #31 from dalbodeule/develop
koin not started bug fix
2024-08-04 19:02:12 +09:00
dalbodeule
514ab14c3c
koin not started bug fix 2024-08-04 18:59:40 +09:00
JinU Choi
13ce148fc5
Merge pull request #30 from dalbodeule/develop
add /user/{uid} endoints.
2024-08-04 18:55:25 +09:00
dalbodeule
4025cbceec
add /user/{uid} call on web module.
- fix some bug with use Pair
2024-08-04 18:51:41 +09:00
dalbodeule
b77a3d02c5
add /user/{uid} call on web module.
- add GetUserEvents.
- add /user/{uid} backends.
2024-08-04 18:47:13 +09:00
dalbodeule
4d63022130
some debugs on ChzzkHandler 2024-08-04 17:25:47 +09:00
JinU Choi
aad10a0f44
Merge pull request #29 from dalbodeule/develop
some debugs on WSTimerRoutes.kt again
2024-08-04 16:11:06 +09:00
dalbodeule
5a050f9d5c
some debugs on WSTimerRoutes.kt again 2024-08-04 16:07:51 +09:00
JinU Choi
2f73204c6a
Merge pull request #28 from dalbodeule/develop
some debugs on WSTimerRoutes.kt
2024-08-04 15:50:30 +09:00
dalbodeule
37b5fda691
some debugs on WSTimerRoutes.kt 2024-08-04 15:47:10 +09:00
JinU Choi
4f31d87b3b
Merge pull request #27 from dalbodeule/develop
some debugs on Chisu playlist
2024-08-04 15:32:54 +09:00
dalbodeule
590c1203bd
some debugs on Chisu playlist 2024-08-04 15:30:00 +09:00
JinU Choi
cc81e6d722
Merge pull request #26 from dalbodeule/develop
add Chisu playlist, etc...
2024-08-04 14:25:51 +09:00
dalbodeule
f7953778e1
some debugs on AddCommand
- add spaces.
2024-08-04 14:10:37 +09:00
dalbodeule
b803aeca1d
add Chisu playlist functions
- add SongLists, SongConfigs to creation table lists.
2024-08-04 14:09:48 +09:00
dalbodeule
91573a4048
add Chisu playlist functions
- add Websocket
- add API
- version up to 1.2.0
2024-08-04 14:09:11 +09:00
dalbodeule
dc81bb09f2
add Chisu playlist functions
- add Playlist, Playlist settings configs.
- add "!노래추가" command
2024-08-04 13:50:05 +09:00
JinU Choi
60319bc6fa
Merge pull request #25 from dalbodeule/develop
some change on LiveStatus logics
2024-08-03 23:47:23 +09:00
dalbodeule
1be1b69425
some change on LiveStatus logics
- add LiveStatus table
- in ChzzkHandler.kt (UserHandler), _isActive variable to bind LiveStatusService
2024-08-03 23:44:10 +09:00
JinU Choi
5b19331890
Merge pull request #24 from dalbodeule/develop
some changes on Connector, RegisterCommand
2024-08-03 21:46:24 +09:00
dalbodeule
d323bf28db
some changes on Connector, RegisterCommand 2024-08-03 21:43:02 +09:00
JinU Choi
b3da9db627
Merge pull request #23 from dalbodeule/develop
add TimerConfig.kt, TimerConfigService
2024-08-03 13:59:55 +09:00
dalbodeule
f7c68a56bc
add TimerConfig.kt, TimerConfigService 2024-08-03 13:57:03 +09:00
JinU Choi
39237a35c9
Merge pull request #22 from dalbodeule/develop
Chzzk uptime is in status.
2024-08-03 09:30:01 +09:00
dalbodeule
55f6f5f94d
Chzzk uptime is in status.
- status:IData<StreamInfo> 's openDate is real uptime.
2024-08-03 09:25:28 +09:00
JinU Choi
65fb2ac3e3
Merge pull request #21 from dalbodeule/develop
in WSTimerRoutes.kt, send data two or many session on one uid.
2024-08-02 15:44:35 +09:00
dalbodeule
e4a2d28b3c
in WSTimerRoutes.kt, send data two or many session on one uid. 2024-08-02 15:42:07 +09:00
JinU Choi
01778b801a
Merge pull request #20 from dalbodeule/develop
add TimerType.STREAM_OFF status.
2024-08-02 15:08:37 +09:00
dalbodeule
8e9382cb3a
add TimerType.STREAM_OFF status.
- if connected on stream off status, TimerType.STREAM_OFF message sent.
- if stream on, default type is TimerType.UPTIME
2024-08-02 14:52:28 +09:00
JinU Choi
070df6b68b
Merge pull request #19 from dalbodeule/develop
some debug on WSTimerRoutes.kt
2024-08-01 21:26:35 +09:00
dalbodeule
65d491cc8e
some debug on WSTimerRoutes.kt 2024-08-01 21:24:20 +09:00
JinU Choi
df0c301079
Merge pull request #18 from dalbodeule/develop
some debug on TimerEvents
2024-08-01 20:37:11 +09:00
dalbodeule
6b533bcee9
some debug on TimerEvents 2024-08-01 20:29:01 +09:00
JinU Choi
2989816a1a
Merge pull request #17 from dalbodeule/develop
debug MessageHandler.kt
2024-07-31 23:45:27 +09:00
dalbodeule
75b983a02b
debug MessageHandler.kt 2024-07-31 23:43:55 +09:00
JinU Choi
b92b11bf06
Merge pull request #16 from dalbodeule/develop
CoroutinesEventBus add.
2024-07-31 16:21:14 +09:00
dalbodeule
180dbc85bf
CoroutinesEventBus add.
- koin dependency injection add
- EventDispatcher fix
2024-07-31 16:14:58 +09:00
JinU Choi
53cefe5813
Merge pull request #15 from dalbodeule/develop
some change on TimerEvents.
2024-07-30 23:01:57 +09:00
dalbodeule
99ec9ba7a0
some change on TimerEvents. 2024-07-30 23:00:29 +09:00
JinU Choi
85ad7fe5ad
Merge pull request #14 from dalbodeule/develop
add WebSocket timers
2024-07-30 22:42:22 +09:00
dalbodeule
a9ee40e936
add WebSocket timers
- EventDispatcher, TimerEvent add.
2024-07-30 22:40:07 +09:00
JinU Choi
da13e8b834
Merge pull request #13 from dalbodeule/develop
debugs some codes.
2024-07-30 16:25:49 +09:00
dalbodeule
65ff475d0a
debugs some codes. 2024-07-30 16:24:18 +09:00
JinU Choi
e8baa393cf
Merge pull request #12 from dalbodeule/develop
debug with eagerLoading
2024-07-30 15:50:11 +09:00
dalbodeule
8365fa1767
debug with eagerLoading
- debug with eagerLoading.

Exposed is lazyLoading. But, This program requires eagerLoading. So, I make eagerLoading methods.
2024-07-30 15:48:39 +09:00
JinU Choi
e16a8b6fc0
Merge pull request #11 from dalbodeule/develop
add webserver, etc...
2024-07-30 14:32:09 +09:00
dalbodeule
72f98b024b
add webserver, etc...
- add ktor webserver
- package is subpackaged.
2024-07-30 14:28:43 +09:00
JinU Choi
1d23ac5121
Merge pull request #10 from dalbodeule/develop
BUG FIX: fix #9
2024-07-29 16:01:30 +09:00
dalbodeule
8f8d2f895a
BUG FIX: fix #9 2024-07-29 15:58:50 +09:00
JinU Choi
47b4b8252f
Merge pull request #8 from dalbodeule/develop
BUG FIX: daily counter with update updated_at column
2024-07-27 21:09:48 +09:00
dalbodeule
64880318e8
BUG FIX: daily counter with update updated_at column 2024-07-27 21:06:56 +09:00
JinU Choi
cec07f9859
Merge pull request #7 from dalbodeule/develop
add guild update, update document, update version to 1.0.1
2024-07-27 11:38:26 +09:00
dalbodeule
18ee27e567
add guild update, update document, update version to 1.0.1 2024-07-27 11:36:17 +09:00
87 changed files with 4373 additions and 1164 deletions

2
.idea/.gitignore generated vendored
View File

@ -6,3 +6,5 @@
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
discord.xml
inspectionProfiles/Project_Default.xml

2
.idea/kotlinc.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.0" />
<option name="version" value="2.0.21" />
</component>
</project>

6
.idea/ktor.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KtorOptions">
<option name="updateOpenAPI" value="true" />
</component>
</project>

8
.idea/modules.xml generated
View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/modules/chzzk_bot.main.iml" filepath="$PROJECT_DIR$/.idea/modules/chzzk_bot.main.iml" />
</modules>
</component>
</project>

9
.idea/nabot_chzzk_bot.iml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

3
.idea/sqldialects.xml generated
View File

@ -4,6 +4,7 @@
<file url="PROJECT" dialect="MariaDB" />
</component>
<component name="SqlResolveMappings">
<file url="file://$PROJECT_DIR$/src/main/kotlin/space/mori/chzzk_bot/models/Command.kt" scope="{&quot;node&quot;:{ &quot;@negative&quot;:&quot;1&quot;, &quot;group&quot;:{ &quot;@kind&quot;:&quot;root&quot;, &quot;node&quot;:{ &quot;@negative&quot;:&quot;1&quot; } } }}" />
<file url="file://$PROJECT_DIR$/common/src/main/kotlin/space/mori/chzzk_bot/common/models/User.kt" scope="{&quot;node&quot;:{ &quot;@negative&quot;:&quot;1&quot;, &quot;group&quot;:{ &quot;@kind&quot;:&quot;root&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;90f8ee11-600e-4155-a316-e8062c7c828b&quot; }, &quot;group&quot;:{ &quot;@kind&quot;:&quot;schema&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;chzzk&quot; } } } } } }}" />
<file url="PROJECT" scope="{&quot;node&quot;:{ &quot;@negative&quot;:&quot;1&quot;, &quot;group&quot;:{ &quot;@kind&quot;:&quot;root&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;90f8ee11-600e-4155-a316-e8062c7c828b&quot; }, &quot;group&quot;:{ &quot;@kind&quot;:&quot;schema&quot;, &quot;node&quot;:{ &quot;name&quot;:{ &quot;@qname&quot;:&quot;chzzk&quot; } } } } } }}" />
</component>
</project>

View File

@ -18,15 +18,38 @@
- [x] \<days:yyyy-mm-dd>
### 관리 명령어 (on Discord)
- [x] /register chzzk_id: \[치지직 고유ID]
- [x] /hook token: \[디스코드 연동 페이지에서 받은 Token]
- [x] /alert channel: \[디스코드 Channel ID] content: \[알림 내용]
- [x] /add label: \[명령어] content: \[내용]
- [ ] /list
- [x] /update label: \[명령어] content: \[내용]
- [x] /delete label: \[명령어]
### 매니저 명령어 (on Discord)
- [x] /addmanager user: \[Discord user]
- [x] /listmanager
- [x] /removemanager user: \[Discord user]
### 관리 명령어 (on Chzzk chat)
- [x] !명령어추가 \[명령어] \[내용]
- [x] !명령어수정 \[명령어] \[내용]
- [x] !명령어삭제 \[명령어]
### 타이머 명령어 (on Chzzk chat, 매니저/스트리머 전용)
- [x] !시간 \[숫자: 분]
- [x] !시간 업타임
- [x] !시간 삭제
### 플레이리스트 명령어 (on Chzzk chat)
- [x] !노래추가 \[유튜브 주소]
- [x] !노래목록
- [ ] !노래삭제 \[번호]
- [ ] !노래설정 \[내용] \[켜기/끄기]
### Envs
- DISCORD_TOKEN
- DB_URL
- DB_USER
- DB_PASS
- RUN_AGENT = `false`
- NID_AUT
- NID_SES
### 사용 예시
- 팔로우
@ -56,3 +79,5 @@
- [mariadb](https://mariadb.org/)
- [docker](https://www.docker.com/)
- [Teamcity](https://www.jetbrains.com/teamcity/)
- [Nuxtjs](https://nuxt.com/)
- [Bulma](https://bulma.io/)

View File

@ -1,11 +1,10 @@
plugins {
val kotlinVersion = "2.0.0"
val kotlinVersion = "2.0.21"
id("java")
id("application")
kotlin("jvm") version kotlinVersion
kotlin("plugin.jpa") version kotlinVersion
id("org.graalvm.buildtools.native") version "0.10.2"
}
group = "${project.group}"
@ -28,43 +27,28 @@ repositories {
}
dependencies {
// https://mvnrepository.com/artifact/net.dv8tion/JDA
implementation("net.dv8tion:JDA:5.0.1") {
exclude(module = "opus-java")
}
// https://mvnrepository.com/artifact/io.github.R2turnTrue/chzzk4j
implementation("io.github.R2turnTrue:chzzk4j:0.0.9")
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.6")
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-core
implementation("org.jetbrains.exposed:exposed-core:0.52.0")
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-dao
implementation("org.jetbrains.exposed:exposed-dao:0.52.0")
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-jdbc
implementation("org.jetbrains.exposed:exposed-jdbc:0.52.0")
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-kotlin-datetime
implementation("org.jetbrains.exposed:exposed-java-time:0.52.0")
// https://mvnrepository.com/artifact/com.zaxxer/HikariCP
implementation("com.zaxxer:HikariCP:5.1.0")
implementation("ch.qos.logback:logback-classic:1.5.13")
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-reflect
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0")
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.21")
// https://mvnrepository.com/artifact/com.google.code.gson/gson
implementation("com.google.code.gson:gson:2.11.0")
// https://mvnrepository.com/artifact/org.mariadb.jdbc/mariadb-java-client
implementation("org.mariadb.jdbc:mariadb-java-client:3.4.1")
// https://mvnrepository.com/artifact/io.github.cdimascio/dotenv-kotlin
implementation("io.github.cdimascio:dotenv-kotlin:6.4.1")
implementation("io.github.cdimascio:dotenv-kotlin:6.4.2")
// https://mvnrepository.com/artifact/io.insert-koin/koin-core
implementation("io.insert-koin:koin-core:4.0.0")
kotlin("stdlib")
listOf(project(":common"), project(":chatbot"), project(":webserver")).forEach {
implementation(it)
}
}
tasks.withType<Jar> {

53
chatbot/build.gradle.kts Normal file
View File

@ -0,0 +1,53 @@
plugins {
kotlin("jvm")
}
group = project.rootProject.group
version = project.rootProject.version
repositories {
mavenCentral()
}
dependencies {
// https://mvnrepository.com/artifact/net.dv8tion/JDA
api("net.dv8tion:JDA:5.2.1") {
exclude(module = "opus-java")
}
// https://mvnrepository.com/artifact/io.github.R2turnTrue/chzzk4j
implementation("io.github.R2turnTrue:chzzk4j:0.1.1")
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.13")
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-reflect
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.21")
// https://mvnrepository.com/artifact/com.google.code.gson/gson
implementation("com.google.code.gson:gson:2.11.0")
// https://mvnrepository.com/artifact/io.github.cdimascio/dotenv-kotlin
implementation("io.github.cdimascio:dotenv-kotlin:6.4.2")
// https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// https://mvnrepository.com/artifact/io.insert-koin/koin-core
implementation("io.insert-koin:koin-core:4.0.0")
testImplementation(kotlin("test"))
listOf(project(":common")).forEach {
implementation(it)
}
}
tasks.test {
useJUnitPlatform()
}
kotlin {
jvmToolchain(21)
}

View File

@ -0,0 +1,370 @@
package space.mori.chzzk_bot.chatbot.chzzk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.Connector.getChannel
import space.mori.chzzk_bot.chatbot.discord.Discord
import space.mori.chzzk_bot.chatbot.utils.refreshAccessToken
import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.LiveStatusService
import space.mori.chzzk_bot.common.services.TimerConfigService
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.*
import xyz.r2turntrue.chzzk4j.ChzzkClient
import xyz.r2turntrue.chzzk4j.session.ChzzkSessionBuilder
import xyz.r2turntrue.chzzk4j.session.ChzzkSessionSubscriptionType
import xyz.r2turntrue.chzzk4j.session.ChzzkUserSession
import xyz.r2turntrue.chzzk4j.session.event.SessionChatMessageEvent
import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel
import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveDetail
import java.lang.Exception
import java.net.SocketTimeoutException
import java.time.LocalDateTime
import java.nio.charset.Charset
object ChzzkHandler {
private val handlers = mutableListOf<UserHandler>()
private val logger = LoggerFactory.getLogger(this::class.java)
lateinit var botUid: String
@Volatile private var running: Boolean = false
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
fun addUser(chzzkChannel: ChzzkChannel, user: User) {
handlers.add(UserHandler(chzzkChannel, logger, user, streamStartTime = LocalDateTime.now()))
}
fun enable() {
botUid = Connector.client.fetchLoggedUser().userId
UserService.getAllUsers().map {
if(!it.isDisabled)
try {
Connector.getChannel(it.token)?.let { token -> addUser(token, it) }
} catch(e: Exception) {
logger.info("Exception: ${it.token}(${it.username}) not found. ${e.stackTraceToString()}")
}
}
handlers.forEach { handler ->
val streamInfo = Connector.getLive(handler.channel.channelId)
if (streamInfo?.isOnline == true) handler.isActive(true, streamInfo)
}
dispatcher.subscribe(UserRegisterEvent::class) {
val channel = getChannel(it.chzzkId)
val user = UserService.getUser(it.chzzkId)
if(channel != null && user != null) {
addUser(channel, user)
}
}
dispatcher.subscribe(CommandReloadEvent::class) {
handlers.firstOrNull { handlers -> handlers.channel.channelId == it.uid }?.reloadCommand()
}
dispatcher.subscribe(BotEnabledEvent::class) {
if(it.isDisabled) {
handlers.removeIf { handlers -> handlers.channel.channelId == it.chzzkId }
} else {
val channel = getChannel(it.chzzkId)
val user = UserService.getUser(it.chzzkId)
if(channel != null && user != null) {
addUser(channel, user)
}
}
}
}
fun disable() {
handlers.forEach { handler ->
handler.disable()
}
}
internal fun reloadCommand(chzzkChannel: ChzzkChannel) {
val handler = handlers.firstOrNull { it.channel.channelId == chzzkChannel.channelId }
if (handler != null)
handler.reloadCommand()
else
throw RuntimeException("${chzzkChannel.channelName} doesn't have handler")
}
internal fun reloadUser(chzzkChannel: ChzzkChannel, user: User) {
val handler = handlers.firstOrNull { it.channel.channelId == chzzkChannel.channelId }
if (handler != null)
handler.reloadUser(user)
else
throw RuntimeException("${chzzkChannel.channelName} doesn't have handler")
}
fun runStreamInfo() {
running = true
val threadRunner1 = Runnable {
logger.info("Thread 1 started!")
while (running) {
handlers.forEach {
if (!running) return@forEach
try {
val streamInfo = Connector.getLive(it.channel.channelId)
if (streamInfo?.isOnline == true && !it.isActive) {
try {
it.isActive(true, streamInfo)
} catch(e: Exception) {
logger.info("Exception: ${e.stackTraceToString()}")
}
}
if (streamInfo?.isOnline == false && it.isActive) it.isActive(false, streamInfo)
} catch (e: SocketTimeoutException) {
logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}")
} catch (e: Exception) {
logger.info("Thread 1 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}")
} finally {
Thread.sleep(5000)
}
}
Thread.sleep(60000)
}
}
val threadRunner2 = Runnable {
logger.info("Thread 2 started!")
logger.info("Thread 2 started!")
while (running) {
handlers.forEach {
if (!running) return@forEach
try {
val streamInfo = Connector.getLive(it.channel.channelId)
if (streamInfo?.isOnline == true && !it.isActive) {
try {
it.isActive(true, streamInfo)
} catch(e: Exception) {
logger.info("Exception: ${e.stackTraceToString()}")
}
}
if (streamInfo?.isOnline == false && it.isActive) it.isActive(false, streamInfo)
} catch (e: SocketTimeoutException) {
logger.info("Thread 1 Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}")
} catch (e: Exception) {
logger.info("Thread 1 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}")
} finally {
Thread.sleep(5000)
}
}
Thread.sleep(60000)
}
}
fun startThread(name: String, runner: Runnable) {
Thread({
while(running) {
try {
val thread = Thread(runner, name)
thread.start()
thread.join()
} catch(e: Exception) {
logger.error("Thread $name Exception: ${e.stackTraceToString()}")
}
if(running) {
logger.info("Thread $name restart in 5 seconds")
Thread.sleep(5000)
}
}
}, "${name}-runner").start()
}
// 첫 번째 스레드 시작
startThread("Chzzk-StreamInfo-1", threadRunner1)
// 85초 대기 후 두 번째 스레드 시작
CoroutineScope(Dispatchers.Default).launch {
delay(95000) // start with 95 secs after.
if (running) {
startThread("Chzzk-StreamInfo-2", threadRunner2)
}
}
}
fun stopStreamInfo() {
running = false
}
}
@OptIn(DelicateCoroutinesApi::class)
class UserHandler(
val channel: ChzzkChannel,
val logger: Logger,
private var user: User,
var streamStartTime: LocalDateTime?,
) {
var messageHandler: MessageHandler
var client: ChzzkClient
var listener: ChzzkUserSession
var chatChannelId: String?
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
private var _isActive: Boolean
get() = LiveStatusService.getLiveStatus(user)?.status ?: false
set(value) {
LiveStatusService.updateOrCreate(user, value)
}
init {
val user = UserService.getUser(channel.channelId)
if(user?.accessToken == null || user.refreshToken == null) {
throw RuntimeException("AccessToken or RefreshToken is not valid.")
}
try {
val tokens = user.refreshToken?.let { token -> Connector.client.refreshAccessToken(token) }
if(tokens == null) {
throw RuntimeException("AccessToken is not valid.")
}
client = Connector.getClient(tokens.first, tokens.second)
UserService.setRefreshToken(user, tokens.first, tokens.second)
chatChannelId = getChzzkChannelId(channel.channelId)
client.loginAsync().join()
listener = ChzzkSessionBuilder(client).buildUserSession()
listener.createAndConnectAsync().join()
messageHandler = MessageHandler(this@UserHandler)
listener.on(SessionChatMessageEvent::class.java) {
messageHandler.handle(it.message, user)
}
GlobalScope.launch {
val timer = TimerConfigService.getConfig(user)
if (timer?.option == TimerType.UPTIME.value)
dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.UPTIME,
getUptime(streamStartTime!!)
)
)
else dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.entries.firstOrNull { it.value == timer?.option } ?: TimerType.REMOVE,
null
)
)
}
} catch(e: Exception) {
logger.error("Exception(${user.username}): ${e.stackTraceToString()}")
throw RuntimeException("Exception: ${e.stackTraceToString()}")
}
}
internal fun disable() {
listener.disconnectAsync().join()
_isActive = false
}
internal fun reloadCommand() {
messageHandler.reloadCommand()
}
internal fun reloadUser(user: User) {
this.user = user
}
internal val isActive: Boolean
get() = _isActive
internal fun isActive(value: Boolean, status: ChzzkLiveDetail) {
if(value) {
CoroutineScope(Dispatchers.Default).launch {
logger.info("${user.username} is live.")
reloadUser(UserService.getUser(user.id.value)!!)
logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}")
listener.subscribeAsync(ChzzkSessionSubscriptionType.CHAT).join()
streamStartTime = LocalDateTime.now()
if(!_isActive) {
_isActive = true
when(TimerConfigService.getConfig(UserService.getUser(channel.channelId)!!)?.option) {
TimerType.UPTIME.value -> dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.UPTIME,
getUptime(streamStartTime!!)
)
)
else -> dispatcher.post(
TimerEvent(
channel.channelId,
TimerType.REMOVE,
""
)
)
}
delay(5000L)
try {
if(!user.isDisableStartupMsg)
sendChat("${user.username} 님! 오늘도 열심히 방송하세요!")
Discord.sendDiscord(user, status)
} catch(e: Exception) {
logger.info("Stream on logic has some error: ${e.stackTraceToString()}")
}
}
}
} else {
logger.info("${user.username} is offline.")
streamStartTime = null
listener.disconnectAsync().join()
_isActive = false
CoroutineScope(Dispatchers.Default).launch {
val events = listOf(
TimerEvent(
channel.channelId,
TimerType.STREAM_OFF,
null
),
SongEvent(
channel.channelId,
SongType.STREAM_OFF,
null,
null,
null,
null,
)
)
events.forEach { dispatcher.post(it) }
}
}
}
private fun String.limitUtf8Length(maxBytes: Int): String {
val bytes = this.toByteArray(Charset.forName("UTF-8"))
if (bytes.size <= maxBytes) return this
var truncatedString = this
while (truncatedString.toByteArray(Charset.forName("UTF-8")).size > maxBytes) {
truncatedString = truncatedString.substring(0, truncatedString.length - 1)
}
return truncatedString
}
@OptIn(DelicateCoroutinesApi::class)
internal fun sendChat(msg: String) {
GlobalScope.launch {
delay(100L)
client.sendChatToLoggedInChannel(msg.limitUtf8Length(100))
}
}
}

View File

@ -0,0 +1,66 @@
package space.mori.chzzk_bot.chatbot.chzzk
import io.github.cdimascio.dotenv.dotenv
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.ChzzkUserFindEvent
import space.mori.chzzk_bot.common.events.ChzzkUserReceiveEvent
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import xyz.r2turntrue.chzzk4j.ChzzkClient
import xyz.r2turntrue.chzzk4j.ChzzkClientBuilder
import xyz.r2turntrue.chzzk4j.auth.ChzzkLegacyLoginAdapter
import xyz.r2turntrue.chzzk4j.auth.ChzzkSimpleUserLoginAdapter
import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel
import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveDetail
import kotlin.getValue
val dotenv = dotenv {
ignoreIfMissing = true
}
@OptIn(DelicateCoroutinesApi::class)
object Connector {
val adapter = ChzzkLegacyLoginAdapter(dotenv["NID_AUT"], dotenv["NID_SES"])
val client: ChzzkClient = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"])
.withLoginAdapter(adapter)
.build()
private val logger = LoggerFactory.getLogger(this::class.java)
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
fun getChannel(channelId: String): ChzzkChannel? = client.fetchChannel(channelId)
fun getLive(channelId: String): ChzzkLiveDetail? = client.fetchLiveDetail(channelId)
init {
logger.info("chzzk logged: ${client.isLoggedIn}")
client.loginAsync().join()
dispatcher.subscribe(ChzzkUserFindEvent::class) { event ->
GlobalScope.launch {
val user = getChannel(event.uid)
dispatcher.post(ChzzkUserReceiveEvent(
find = user != null,
uid = user?.channelId,
nickname = user?.channelName,
isStreamOn = user?.isBroadcasting,
avatarUrl = user?.channelImageUrl
))
}
}
}
fun getClient(accessToken: String, refreshToken: String): ChzzkClient {
val adapter = ChzzkSimpleUserLoginAdapter(accessToken, refreshToken)
val client = ChzzkClientBuilder(dotenv["NAVER_CLIENT_ID"], dotenv["NAVER_CLIENT_SECRET"])
.withLoginAdapter(adapter)
.build()
return client
}
}

View File

@ -0,0 +1,400 @@
package space.mori.chzzk_bot.chatbot.chzzk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.chatbot.discord.Discord.Companion.bot
import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.*
import space.mori.chzzk_bot.common.utils.getFollowDate
import space.mori.chzzk_bot.common.utils.getUptime
import space.mori.chzzk_bot.common.utils.getYoutubeVideo
import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
import xyz.r2turntrue.chzzk4j.session.ChzzkUserSession
import xyz.r2turntrue.chzzk4j.session.message.SessionChatMessage
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
class MessageHandler(
private val handler: UserHandler
) {
private val commands = mutableMapOf<String, (msg: SessionChatMessage, user: User) -> Unit>()
private val counterPattern = Regex("<counter:([^>]+)>")
private val personalCounterPattern = Regex("<counter_personal:([^>]+)>")
private val dailyCounterPattern = Regex("<daily_counter:([^>]+)>")
private val namePattern = Regex("<name>")
private val followPattern = Regex("<following>")
private val daysPattern = """<days:(\d{4})-(\d{2})-(\d{2})>""".toRegex()
private val channel = handler.channel
private val logger = handler.logger
private val listener = handler.listener
private val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
init {
reloadCommand()
dispatcher.subscribe(SongEvent::class) {
if(it.type == SongType.STREAM_OFF) {
val user = UserService.getUser(channel.channelId)
if(! user?.let { usr -> SongListService.getSong(usr) }.isNullOrEmpty()) {
SongListService.deleteUser(user)
}
}
}
}
internal fun reloadCommand() {
val user = UserService.getUser(channel.channelId)
?: throw RuntimeException("User not found. it's bug? ${channel.channelName} - ${channel.channelId}")
val commands = CommandService.getCommands(user)
val manageCommands = mapOf(
"!명령어" to this::commandListCommand,
"!명령어추가" to this::manageAddCommand,
"!명령어삭제" to this::manageRemoveCommand,
"!명령어수정" to this::manageUpdateCommand,
"!시간" to this::timerCommand,
"!신청곡" to this::songAddCommand,
"!노래목록" to this::songListCommand,
"!노래시작" to this::songStartCommand,
)
manageCommands.forEach { (commandName, command) ->
this.commands[commandName] = command
}
commands.map {
this.commands.put(it.command.lowercase()) { msg, user ->
logger.debug("${channel.channelName} - ${it.command} - ${it.content}/${it.failContent}")
val result = replaceCounters(
Pair(it.content, it.failContent),
user,
msg,
msg.profile?.nickname ?: ""
)
handler.sendChat(result)
}
}
}
private fun commandListCommand(msg: SessionChatMessage, user: User) {
handler.sendChat("리스트는 여기입니다. https://nabot.mori.space/commands/${user.token}")
}
private fun manageAddCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 명령어를 추가할 수 있습니다.")
return
}
val parts = msg.content.split(" ", limit = 3)
if (parts.size < 3) {
handler.sendChat("명령어 추가 형식은 '!명령어추가 명령어 내용'입니다.")
return
}
if (commands.containsKey(parts[1])) {
handler.sendChat("${parts[1]} 명령어는 이미 있는 명령어입니다.")
return
}
val command = parts[1]
val content = parts[2]
CommandService.saveCommand(user, command, content, "")
handler.sendChat("명령어 '$command' 추가되었습니다.")
}
private fun manageUpdateCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 명령어를 추가할 수 있습니다.")
return
}
val parts = msg.content.split(" ", limit = 3)
if (parts.size < 3) {
handler.sendChat("명령어 수정 형식은 '!명령어수정 명령어 내용'입니다.")
return
}
if (!commands.containsKey(parts[1])) {
handler.sendChat("${parts[1]} 명령어는 없는 명령어입니다.")
return
}
val command = parts[1]
val content = parts[2]
CommandService.updateCommand(user, command, content, "")
handler.sendChat("명령어 '$command' 수정되었습니다.")
ChzzkHandler.reloadCommand(channel)
}
private fun manageRemoveCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 명령어를 삭제할 수 있습니다.")
return
}
val parts = msg.content.split(" ", limit = 2)
if (parts.size < 2) {
handler.sendChat("명령어 삭제 형식은 '!명령어삭제 명령어'입니다.")
return
}
val command = parts[1]
CommandService.removeCommand(user, command)
handler.sendChat("명령어 '$command' 삭제되었습니다.")
ChzzkHandler.reloadCommand(channel)
}
private fun timerCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return
}
val parts = msg.content.split(" ", limit = 3)
if (parts.size < 2) {
handler.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!")
return
}
val command = parts[1]
when (parts[1]) {
"업타임" -> {
logger.debug("${user.token} / 업타임")
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(
TimerEvent(
user.token,
TimerType.UPTIME,
getUptime(handler.streamStartTime!!)
)
)
}
}
"삭제" -> {
logger.debug("${user.token} / 삭제")
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(TimerEvent(user.token, TimerType.REMOVE, ""))
}
}
"설정" -> {
when (parts[2]) {
"업타임" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.UPTIME)
handler.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.")
}
"삭제" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.REMOVE)
handler.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.")
}
else -> handler.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!")
}
}
else -> {
logger.debug("${user.token} / 그외")
try {
val time = command.toInt()
val currentTime = LocalDateTime.now()
val timestamp = currentTime.plus(time.toLong(), ChronoUnit.MINUTES)
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(TimerEvent(user.token, TimerType.TIMER, timestamp.toString()))
}
} catch (e: NumberFormatException) {
handler.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분")
} catch (e: Exception) {
handler.sendChat("타이머 설정 중 오류가 발생했습니다.")
logger.error("Error processing timer command: ${e.message}", e)
}
}
}
}
// songs
private fun songAddCommand(msg: SessionChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) {
return
}
val parts = msg.content.split(" ", limit = 2)
if (parts.size < 2) {
handler.sendChat("유튜브 URL을 입력해주세요!")
return
}
val config = SongConfigService.getConfig(user)
if(config.streamerOnly && msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return
}
val url = parts[1]
val songs = SongListService.getSong(user)
if(songs.size >= config.queueLimit) {
handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return
}
if(songs.filter { it.uid == msg.senderChannelId }.size >= config.personalLimit) {
handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return
}
try {
val video = getYoutubeVideo(url)
if (video == null) {
handler.sendChat("유튜브에서 찾을 수 없어요!")
return
}
if (songs.any { it.url == video.url }) {
handler.sendChat("같은 노래가 이미 신청되어 있습니다.")
return
}
if (video.length > 600) {
handler.sendChat("10분이 넘는 노래는 신청할 수 없습니다.")
return
}
SongListService.saveSong(
user,
msg.senderChannelId,
video.url,
video.name,
video.author,
video.length,
msg.profile?.nickname ?: ""
)
CoroutineScope(Dispatchers.Default).launch {
dispatcher.post(
SongEvent(
user.token,
SongType.ADD,
msg.senderChannelId,
null,
video,
)
)
}
handler.sendChat("노래가 추가되었습니다. ${video.name} - ${video.author}")
} catch(e: Exception) {
handler.sendChat("유튜브 영상 주소로 다시 신청해주세요!")
logger.info(e.stackTraceToString())
}
}
private fun songListCommand(msg: SessionChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) {
return
}
handler.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}")
}
private fun songStartCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return
}
if(user.discord != null) {
bot.retrieveUserById(user.discord!!).queue { discordUser ->
discordUser?.openPrivateChannel()?.queue { channel ->
channel.sendMessage("여기로 접속해주세요! ||https://nabot.mori.space/songlist||.")
.queue()
}
}
} else {
handler.sendChat("나봇 홈페이지의 노래목록 페이지를 이용해주세요! 디스코드 연동을 하시면 DM으로 바로 전송됩니다.")
}
}
internal fun handle(msg: SessionChatMessage, user: User) {
if(msg.senderChannelId == ChzzkHandler.botUid) return
val commandKey = msg.content.split(' ')[0]
commands[commandKey.lowercase()]?.let { it(msg, user) }
}
private fun replaceCounters(chat: Pair<String, String>, user: User, msg: SessionChatMessage, userName: String): String {
var result = chat.first
var isFail = false
// Replace dailyCounterPattern
result = dailyCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1]
val dailyCounter = CounterService.getDailyCounterValue(name, msg.senderChannelId, user)
if (dailyCounter.second) {
CounterService.updateDailyCounterValue(name, msg.senderChannelId, 1, user).first.toString()
} else {
isFail = true
dailyCounter.first.toString()
}
}
// Handle fail case
if (isFail && chat.second.isNotEmpty()) {
result = chat.second
result = dailyCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1]
val dailyCounter = CounterService.getDailyCounterValue(name, msg.senderChannelId, user)
dailyCounter.first.toString()
}
}
// Replace followPattern
result = followPattern.replace(result) { _ ->
try {
val followingDate = handler.chatChannelId?.let { getFollowDate(it, msg.senderChannelId) }
?.content?.streamingProperty?.following?.followDate ?: LocalDateTime.now().minusDays(1).toString()
val period = followingDate.let {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val pastDate = LocalDateTime.parse(it, formatter)
val today = LocalDateTime.now()
ChronoUnit.DAYS.between(pastDate, today)
} + 1
period.toString()
} catch (e: Exception) {
logger.error(e.message)
"0"
}
}
// Replace daysPattern
result = daysPattern.replace(result) { matchResult ->
val (year, month, day) = matchResult.destructured
val pastDate = LocalDateTime.of(year.toInt(), month.toInt(), day.toInt(), 0, 0, 0)
val today = LocalDateTime.now()
val daysBetween = ChronoUnit.DAYS.between(pastDate, today)
daysBetween.toString()
}
// Replace counterPattern
result = counterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1]
CounterService.updateCounterValue(name, 1, user).toString()
}
// Replace personalCounterPattern
result = personalCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1]
CounterService.updatePersonalCounterValue(name, msg.senderChannelId, 1, user).toString()
}
// Replace namePattern
result = namePattern.replace(result, userName)
return result
}
}

View File

@ -0,0 +1,11 @@
package space.mori.chzzk_bot.chatbot.chzzk
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.CommandService
import xyz.r2turntrue.chzzk4j.chat.ChatMessage
class SongModule {
companion object {
}
}

View File

@ -1,4 +1,4 @@
package space.mori.chzzk_bot.discord
package space.mori.chzzk_bot.chatbot.discord
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent

View File

@ -0,0 +1,129 @@
package space.mori.chzzk_bot.chatbot.discord
import io.github.cdimascio.dotenv.dotenv
import net.dv8tion.jda.api.EmbedBuilder
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.JDABuilder
import net.dv8tion.jda.api.entities.Activity
import net.dv8tion.jda.api.entities.Guild
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel
import net.dv8tion.jda.api.events.guild.GuildJoinEvent
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.hooks.ListenerAdapter
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.discord.commands.*
import space.mori.chzzk_bot.common.models.User
import xyz.r2turntrue.chzzk4j.types.channel.live.ChzzkLiveDetail
import xyz.r2turntrue.chzzk4j.types.channel.live.Resolution
import java.time.Instant
import kotlin.jvm.optionals.getOrNull
val dotenv = dotenv {
ignoreIfMissing = true
}
class Discord: ListenerAdapter() {
private var guild: Guild? = null
private val logger = LoggerFactory.getLogger(this::class.java)
companion object {
lateinit var bot: JDA
internal fun getChannel(guildId: Long, channelId: Long): TextChannel? {
return bot.getGuildById(guildId)?.getTextChannelById(channelId)
}
fun sendDiscord(user: User, status: ChzzkLiveDetail) {
if(user.liveAlertMessage != null && user.liveAlertGuild != null && user.liveAlertChannel != null) {
val channel = getChannel(user.liveAlertGuild ?: 0, user.liveAlertChannel ?: 0)
?: throw RuntimeException("${user.liveAlertChannel} is not valid.")
val embed = EmbedBuilder()
embed.setTitle(status.title, "https://chzzk.naver.com/live/${user.token}")
embed.setDescription("${user.username} 님이 방송을 시작했습니다.")
embed.setTimestamp(Instant.now())
embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}")
embed.addField("카테고리", status.liveCategoryValue, true)
embed.addField("태그", status.tags.joinToString(", ") { it.trim() }, true)
status.defaultThumbnailImageUrl.getOrNull()?.let { embed.setImage(it) }
?: Resolution.entries.reversed().forEach {
val thumbnail = status.getLiveImageUrl(it)
if (thumbnail != null) {
embed.setImage(thumbnail)
return@forEach
}
}
channel.sendMessage(
MessageCreateBuilder()
.setContent(user.liveAlertMessage)
.setEmbeds(embed.build())
.build()
).queue()
}
}
}
private val commands = listOf(
PingCommand,
)
override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) {
event.deferReply().queue()
val handler = commands.find { it.name == event.name }
logger.debug("Handler: ${handler?.name ?: "undefined"} command")
handler?.run(event, bot)
}
override fun onGuildJoin(event: GuildJoinEvent) {
commandUpdate(event.guild)
}
private fun commandUpdate(guild: Guild) {
guild.updateCommands().addCommands(* commands.map { it.command}.toTypedArray())
.onSuccess {
logger.info("Command update on guild success!")
}
.queue()
}
private fun commandUpdate(bot: JDA) {
bot.updateCommands().addCommands(* commands.map { it.command}.toTypedArray())
.onSuccess {
logger.info("Command update bot boot success!")
}
.queue()
}
fun enable() {
val thread = Thread {
try {
bot = JDABuilder.createDefault(dotenv["DISCORD_TOKEN"])
.setActivity(Activity.playing("치지직 보는중"))
.addEventListeners(this)
.build().awaitReady()
guild = bot.getGuildById(dotenv["GUILD_ID"])
commandUpdate(bot)
bot.guilds.forEach {
commandUpdate(it)
}
} catch (e: Exception) {
logger.info("Could not enable Discord!")
logger.debug(e.stackTraceToString())
}
}
thread.start()
}
fun disable() {
try {
bot.shutdown()
} catch(e: Exception) {
logger.info("Error while shutting down Discord!")
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -1,10 +1,10 @@
package space.mori.chzzk_bot.discord.commands
package space.mori.chzzk_bot.chatbot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.build.Commands
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.discord.CommandInterface
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
object PingCommand: CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)

View File

@ -0,0 +1,58 @@
package space.mori.chzzk_bot.chatbot.utils
import com.google.gson.Gson
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import space.mori.chzzk_bot.chatbot.chzzk.dotenv
import space.mori.chzzk_bot.common.utils.client
import xyz.r2turntrue.chzzk4j.ChzzkClient
import java.io.IOException
val client = OkHttpClient.Builder()
.addNetworkInterceptor { chain ->
chain.proceed(
chain.request()
.newBuilder()
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
.build()
)
}
.build()
val gson = Gson()
data class RefreshTokenResponse(
val accessToken: String,
val refreshToken: String,
val expiresIn: Int,
val tokenType: String = "Bearer",
val scope: String
)
fun ChzzkClient.refreshAccessToken(refreshToken: String): Pair<String, String> {
val url = "https://openapi.chzzk.naver.com/auth/v1/token"
val request = Request.Builder()
.url(url)
.header("Content-Type", "application/json")
.post(gson.toJson(mapOf(
"grantType" to "refresh_token",
"refreshToken" to refreshToken,
"clientId" to dotenv["NAVER_CLIENT_ID"],
"clientSecret" to dotenv["NAVER_CLIENT_SECRET"]
)).toRequestBody("application/json; charset=utf-8".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
try {
if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}")
val body = response.body?.string()
val data = gson.fromJson(body, RefreshTokenResponse::class.java)
return Pair(data.accessToken, data.refreshToken)
} catch(e: Exception) {
throw e
}
}
}

48
common/build.gradle.kts Normal file
View File

@ -0,0 +1,48 @@
plugins {
kotlin("jvm")
}
group = project.rootProject.group
version = project.rootProject.version
repositories {
mavenCentral()
}
dependencies {
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-core
api("org.jetbrains.exposed:exposed-core:0.56.0")
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-dao
api("org.jetbrains.exposed:exposed-dao:0.56.0")
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-jdbc
api("org.jetbrains.exposed:exposed-jdbc:0.56.0")
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-kotlin-datetime
api("org.jetbrains.exposed:exposed-java-time:0.56.0")
// https://mvnrepository.com/artifact/com.zaxxer/HikariCP
api("com.zaxxer:HikariCP:6.1.0")
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.13")
// https://mvnrepository.com/artifact/org.mariadb.jdbc/mariadb-java-client
implementation("org.mariadb.jdbc:mariadb-java-client:3.5.0")
// https://mvnrepository.com/artifact/io.github.cdimascio/dotenv-kotlin
implementation("io.github.cdimascio:dotenv-kotlin:6.4.2")
// https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// https://mvnrepository.com/artifact/com.google.code.gson/gson
implementation("com.google.code.gson:gson:2.11.0")
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
kotlin {
jvmToolchain(21)
}

View File

@ -1,11 +1,16 @@
package space.mori.chzzk_bot
package space.mori.chzzk_bot.common
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.github.cdimascio.dotenv.dotenv
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.models.*
import space.mori.chzzk_bot.common.models.*
val dotenv = dotenv {
ignoreIfMissing = true
}
object Connector {
private val hikariConfig = HikariConfig().apply {
@ -19,7 +24,19 @@ object Connector {
init {
Database.connect(dataSource)
val tables = listOf(Users, Commands, Counters, DailyCounters, PersonalCounters, Managers)
val tables = listOf(
UserManagers,
Users,
Commands,
Counters,
DailyCounters,
PersonalCounters,
TimerConfigs,
LiveStatuses,
SongLists,
SongConfigs,
Sessions
)
transaction {
SchemaUtils.createMissingTablesAndColumns(* tables.toTypedArray())

View File

@ -0,0 +1,8 @@
package space.mori.chzzk_bot.common.events
data class BotEnabledEvent(
val chzzkId: String,
val isDisabled: Boolean,
): Event {
val TAG = javaClass.simpleName
}

View File

@ -0,0 +1,7 @@
package space.mori.chzzk_bot.common.events
data class ChzzkUserFindEvent(
val uid: String
): Event {
val TAG = javaClass.simpleName
}

View File

@ -0,0 +1,11 @@
package space.mori.chzzk_bot.common.events
data class ChzzkUserReceiveEvent(
val find: Boolean = true,
val uid: String? = null,
val nickname: String? = null,
val isStreamOn: Boolean? = null,
val avatarUrl: String? = null,
): Event {
val TAG = javaClass.simpleName
}

View File

@ -0,0 +1,7 @@
package space.mori.chzzk_bot.common.events
data class CommandReloadEvent(
val uid: String
): Event {
val TAG = javaClass.simpleName
}

View File

@ -0,0 +1,8 @@
package space.mori.chzzk_bot.common.events
class DiscordRegisterEvent(
val user: String,
val token: String,
): Event {
val TAG = javaClass.simpleName
}

View File

@ -0,0 +1,32 @@
package space.mori.chzzk_bot.common.events
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import kotlin.reflect.KClass
interface Event
interface EventBus {
suspend fun <T: Event> post(event: T)
fun <T: Event> subscribe(eventClass: KClass<T>, listener: (T) -> Unit)
}
class CoroutinesEventBus: EventBus {
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> get() = _events
override suspend fun<T: Event> post(event: T) = _events.emit(event)
override fun <T: Event> subscribe(eventClass: KClass<T>, listener: (T) -> Unit) {
CoroutineScope(Dispatchers.Default).launch {
events.filterIsInstance(eventClass)
.collect {
listener(it)
}
}
}
}

View File

@ -0,0 +1,23 @@
package space.mori.chzzk_bot.common.events
import space.mori.chzzk_bot.common.utils.YoutubeVideo
enum class SongType(var value: Int) {
ADD(0),
REMOVE(1),
NEXT(2),
STREAM_OFF(50),
ACK(51)
}
class SongEvent(
val uid: String,
val type: SongType,
val reqUid: String?,
val current: YoutubeVideo? = null,
val next: YoutubeVideo? = null,
val delUrl: String? = null,
): Event {
var TAG = javaClass.simpleName
}

View File

@ -0,0 +1,18 @@
package space.mori.chzzk_bot.common.events
enum class TimerType(var value: Int) {
UPTIME(0),
TIMER(1),
REMOVE(2),
STREAM_OFF(50),
ACK(51)
}
class TimerEvent(
val uid: String,
val type: TimerType,
val time: String?
): Event {
var TAG = javaClass.simpleName
}

View File

@ -0,0 +1,7 @@
package space.mori.chzzk_bot.common.events
data class UserRegisterEvent(
val chzzkId: String
): Event {
val TAG = javaClass.simpleName
}

View File

@ -1,4 +1,4 @@
package space.mori.chzzk_bot.models
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass

View File

@ -1,4 +1,4 @@
package space.mori.chzzk_bot.models
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass

View File

@ -1,4 +1,4 @@
package space.mori.chzzk_bot.models
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass

View File

@ -0,0 +1,19 @@
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ReferenceOption
object LiveStatuses: IntIdTable("live_statuses") {
val user = reference("user", Users, onDelete = ReferenceOption.CASCADE)
val status = bool("status")
}
class LiveStatus(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<LiveStatus>(LiveStatuses)
var user by User referencedOn LiveStatuses.user
var status by LiveStatuses.status
}

View File

@ -1,4 +1,4 @@
package space.mori.chzzk_bot.models
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass

View File

@ -0,0 +1,18 @@
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
object Sessions: IntIdTable("session") {
val key = text("key")
val value = text("value")
}
class Session(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Session>(Sessions)
var key by Sessions.key
var value by Sessions.value
}

View File

@ -0,0 +1,26 @@
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ReferenceOption
object SongConfigs: IntIdTable("song_config") {
val user = reference("user", Users, onDelete = ReferenceOption.CASCADE)
val token = varchar("token", 64).nullable()
val streamerOnly = bool("streamer_only").default(false)
val queueLimit = integer("queue_limit").default(50)
val personalLimit = integer("personal_limit").default(5)
val disabled = bool("disabled").default(false)
}
class SongConfig(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<SongConfig>(SongConfigs)
var user by User referencedOn SongConfigs.user
var token by SongConfigs.token
var streamerOnly by SongConfigs.streamerOnly
var queueLimit by SongConfigs.queueLimit
var personalLimit by SongConfigs.personalLimit
var disabled by SongConfigs.disabled
}

View File

@ -0,0 +1,33 @@
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.javatime.datetime
import java.time.LocalDateTime
object SongLists: IntIdTable("song_list") {
val user = reference("user", Users)
val uid = varchar("uid", 64)
val url = varchar("url", 128)
val name = text("name")
val reqName = varchar("req_name", 80)
val author = text("author")
val time = integer("time")
val created_at = datetime("created_at").default(LocalDateTime.now())
}
class SongList(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<SongList>(SongLists)
var url by SongLists.url
var name by SongLists.name
var author by SongLists.author
var time by SongLists.time
var created_at by SongLists.created_at
var user by User referencedOn SongLists.user
var uid by SongLists.uid
var reqName by SongLists.reqName
}

View File

@ -0,0 +1,18 @@
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ReferenceOption
object TimerConfigs: IntIdTable("timer_config") {
val user = reference("user", Users, onDelete = ReferenceOption.CASCADE)
val option = integer("option")
}
class TimerConfig(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<TimerConfig>(TimerConfigs)
var user by User referencedOn TimerConfigs.user
var option by TimerConfigs.option
}

View File

@ -1,4 +1,4 @@
package space.mori.chzzk_bot.models
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
@ -9,10 +9,14 @@ import org.jetbrains.exposed.dao.id.IntIdTable
object Users: IntIdTable("users") {
val username = varchar("username", 255)
val token = varchar("token", 64)
val discord = long("discord")
val discord = long("discord").nullable()
val liveAlertGuild = long("live_alert_guild").nullable()
val liveAlertChannel = long("live_alert_channel").nullable()
val liveAlertMessage = text("live_alert_message").nullable()
val isDisableStartupMsg = bool("is_disable_startup_msg").default(false)
val isDisabled = bool("is_disabled").default(false)
val accessToken = varchar("access_token", 255).nullable()
val refreshToken = varchar("refresh_token", 255).nullable()
}
class User(id: EntityID<Int>) : IntEntity(id) {
@ -24,4 +28,15 @@ class User(id: EntityID<Int>) : IntEntity(id) {
var liveAlertGuild by Users.liveAlertGuild
var liveAlertChannel by Users.liveAlertChannel
var liveAlertMessage by Users.liveAlertMessage
var isDisableStartupMsg by Users.isDisableStartupMsg
var isDisabled by Users.isDisabled
var accessToken by Users.accessToken
var refreshToken by Users.refreshToken
// 유저가 가진 매니저들
var managers by User.via(UserManagers.user, UserManagers.manager)
// 매니저가 관리하는 유저들
var subordinates by User.via(UserManagers.manager, UserManagers.user)
}

View File

@ -0,0 +1,9 @@
package space.mori.chzzk_bot.common.models
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ReferenceOption
object UserManagers: IntIdTable("user_managers") {
val user = reference("user_id", Users, ReferenceOption.CASCADE)
val manager = reference("manager_id", Users, ReferenceOption.CASCADE)
}

View File

@ -1,17 +1,17 @@
package space.mori.chzzk_bot.services
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import space.mori.chzzk_bot.models.Command
import space.mori.chzzk_bot.models.Commands
import space.mori.chzzk_bot.models.User
import space.mori.chzzk_bot.common.models.Command
import space.mori.chzzk_bot.common.models.Commands
import space.mori.chzzk_bot.common.models.User
object CommandService {
fun saveCommand(user: User, command: String, content: String, failContent: String): Command {
return transaction {
return@transaction Command.new {
Command.new {
this.user = user
this.command = command
this.content = content

View File

@ -1,8 +1,8 @@
package space.mori.chzzk_bot.services
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.models.*
import space.mori.chzzk_bot.common.models.*
import java.time.LocalDate
object CounterService {
@ -96,6 +96,7 @@ object CounterService {
Pair(counter.value, false)
else {
counter.value += increment
counter.updatedAt = today
Pair(counter.value, true)
}
}

View File

@ -0,0 +1,30 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.LiveStatus
import space.mori.chzzk_bot.common.models.LiveStatuses
import space.mori.chzzk_bot.common.models.User
object LiveStatusService {
fun updateOrCreate(user: User, status: Boolean): LiveStatus {
return transaction {
return@transaction when(val liveStatus = LiveStatus.find(LiveStatuses.user eq user.id).firstOrNull()) {
null -> LiveStatus.new {
this.user = user
this.status = status
}
else -> {
liveStatus.status = status
liveStatus
}
}
}
}
fun getLiveStatus(user: User): LiveStatus? {
return transaction {
LiveStatus.find(LiveStatuses.user eq user.id).firstOrNull()
}
}
}

View File

@ -0,0 +1,73 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.SongConfig
import space.mori.chzzk_bot.common.models.SongConfigs
import space.mori.chzzk_bot.common.models.User
object SongConfigService {
private fun initConfig(user: User): SongConfig {
return transaction {
SongConfig.new {
this.user = user
}
}
}
fun getConfig(user: User): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig
}
}
fun updatePersonalLimit(user: User, limit: Int): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig.personalLimit = limit
songConfig
}
}
fun updateQueueLimit(user: User, limit: Int): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig.queueLimit = limit
songConfig
}
}
fun updateStreamerOnly(user: User, config: Boolean): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig.streamerOnly = config
songConfig
}
}
fun updateDisabled(user: User, config: Boolean): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig.disabled = config
songConfig
}
}
}

View File

@ -0,0 +1,63 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.SongList
import space.mori.chzzk_bot.common.models.SongLists
import space.mori.chzzk_bot.common.models.User
object SongListService {
fun saveSong(user: User, uid: String, url: String, name: String, author: String, time: Int, reqName: String) {
return transaction {
SongList.new {
this.user = user
this.uid = uid
this.url = url
this.name = name
this.author = author
this.time = time
this.reqName = reqName
}
}
}
fun getSong(user: User, uid: String): List<SongList> {
return transaction {
SongList.find(
(SongLists.user eq user.id) and
(SongLists.uid eq uid)
).toList()
}
}
fun getSong(user: User): List<SongList> {
return transaction {
SongList.find(SongLists.user eq user.id).toList().sortedBy { it.created_at }
}
}
fun deleteSong(user: User, uid: String, name: String): SongList {
return transaction {
val songRow = SongList.find(
(SongLists.user eq user.id) and
(SongLists.uid eq uid) and
(SongLists.name eq name)
).firstOrNull()
songRow ?: throw RuntimeException("Song not found! ${user.username} / $uid / $name")
songRow.delete()
songRow
}
}
fun deleteUser(user: User): Boolean {
return transaction {
val songRow = SongList.find(SongLists.user eq user.id).toList()
songRow.forEach { it.delete() }
true
}
}
}

View File

@ -0,0 +1,48 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import space.mori.chzzk_bot.common.events.TimerType
import space.mori.chzzk_bot.common.models.TimerConfig
import space.mori.chzzk_bot.common.models.TimerConfigs
import space.mori.chzzk_bot.common.models.User
object TimerConfigService {
fun saveConfig(user: User, timerConfig: TimerType) {
return transaction {
TimerConfig.new {
this.user = user
this.option = timerConfig.value
}
}
}
fun updateConfig(user: User, timerConfig: TimerType) {
return transaction {
val updated = TimerConfigs.update({
TimerConfigs.user eq user.id
}) {
it[option] = timerConfig.value
}
if (updated == 0) throw RuntimeException("TimerConfig not found! ${user.username}")
TimerConfig.find { TimerConfigs.user eq user.id }.first()
}
}
fun getConfig(user: User): TimerConfig? {
return transaction {
TimerConfig.find(TimerConfigs.user eq user.id).firstOrNull()
}
}
fun saveOrUpdateConfig(user: User, timerConfig: TimerType) {
return if (getConfig(user) == null) {
saveConfig(user, timerConfig)
} else {
updateConfig(user, timerConfig)
}
}
}

View File

@ -0,0 +1,115 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.dao.load
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.models.Users
object UserService {
fun saveUser(username: String, token: String): User {
return transaction {
User.new {
this.username = username
this.token = token
}
}
}
fun updateUser(user: User, chzzkId: String, username: String): User {
return transaction {
user.token = chzzkId
user.username = username
user
}
}
fun updateUser(user: User, discordID: Long): User {
return transaction {
user.discord = discordID
user.load(User::subordinates, User::managers)
user
}
}
fun getUser(id: Int): User? {
return transaction {
val user = User.find{ Users.id eq id }.firstOrNull()
user?.load(User::subordinates, User::managers)
user
}
}
fun getUser(discordID: Long): User? {
return transaction {
val user = User.find{ Users.discord eq discordID }.firstOrNull()
user?.load(User::subordinates, User::managers)
user
}
}
fun getUser(chzzkID: String): User? {
return transaction {
val user = User.find{ Users.token eq chzzkID }.firstOrNull()
user?.load(User::subordinates, User::managers)
user
}
}
fun getUserWithGuildId(discordGuildId: Long): User? {
return transaction {
val user = User.find { Users.liveAlertGuild eq discordGuildId }.firstOrNull()
user?.load(User::subordinates, User::managers)
user
}
}
fun getAllUsers(): List<User> {
return transaction {
User.all().toList()
}
}
fun updateLiveAlert(user: User, guildId: Long, channelId: Long, alertMessage: String?): User {
return transaction {
user.liveAlertGuild = guildId
user.liveAlertChannel = channelId
user.liveAlertMessage = alertMessage ?: ""
user.load(User::subordinates, User::managers)
user
}
}
fun setIsDisabled(user: User, disabled: Boolean): User {
return transaction {
user.isDisabled = disabled
user
}
}
fun setIsStartupDisabled(user: User, disabled: Boolean): User {
return transaction {
user.isDisableStartupMsg = disabled
user
}
}
fun setAccessToken(user: User, accessToken: String): User {
return transaction {
user.accessToken = accessToken
user
}
}
fun setRefreshToken(user: User, accessToken: String, refreshToken: String): User {
return transaction {
user.accessToken = accessToken
user.refreshToken = refreshToken
user
}
}
}

View File

@ -1,4 +1,4 @@
package space.mori.chzzk_bot.chzzk
package space.mori.chzzk_bot.common.utils
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
@ -52,47 +52,18 @@ data class NicknameColor(
val colorCode: String = ""
)
// Stream info
data class IStreamInfo(
val liveId: Int = 0,
val liveTitle: String = "",
val status: String = "",
val liveImageUrl: String = "",
val defaultThumbnailImageUrl: String? = null,
val concurrentUserCount: Int = 0,
val accumulateCount: Int = 0,
val openDate: String = "",
val closeDate: String = "",
val adult: Boolean = false,
val clipActive: Boolean = false,
val tags: List<String> = emptyList(),
val chatChannelId: String = "",
val categoryType: String = "",
val liveCategory: String = "",
val liveCategoryValue: String = "",
val chatActive: Boolean = true,
val chatAvailableGroup: String = "",
val paidPromotion: Boolean = false,
val chatAvailableCondition: String = "",
val minFollowerMinute: Int = 0,
val livePlaybackJson: String = "",
val p2pQuality: List<Any> = emptyList(),
val channel: Channel = Channel(),
val livePollingStatusJson: String = "",
val userAdultStatus: String? = null,
val chatDonationRankingExposure: Boolean = true,
val adParameter: AdParameter = AdParameter()
)
data class Channel(
val channelId: String = "",
val channelName: String = "",
val channelImageUrl: String = "",
val verifiedMark: Boolean = false
)
data class AdParameter(
val tag: String = ""
data class LiveStatus(
val liveTitle: String,
val status: String,
val concurrentUserCount: Int,
val accumulateCount: Int,
val paidPromotion: Boolean,
val adult: Boolean,
val krOnlyViewing: Boolean,
val openDate: String,
val closeDate: String?,
val clipActive: Boolean,
val chatChannelId: String
)
// OkHttpClient에 Interceptor 추가
@ -108,7 +79,7 @@ val client = OkHttpClient.Builder()
.build()
val gson = Gson()
fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent> {
fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent?> {
val url = "https://comm-api.game.naver.com/nng_main/v1/chats/$chatID/users/$userId/profile-card?chatType=STREAMING"
val request = Request.Builder()
.url(url)
@ -118,7 +89,7 @@ fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent> {
try {
if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}")
val body = response.body?.string()
val follow = gson.fromJson(body, object: TypeToken<IData<IFollowContent>>() {})
val follow = gson.fromJson(body, object: TypeToken<IData<IFollowContent?>>() {})
return follow
} catch(e: Exception) {
@ -128,21 +99,23 @@ fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent> {
}
}
fun getStreamInfo(userId: String) : IData<IStreamInfo> {
val url = "https://api.chzzk.naver.com/service/v2/channels/${userId}/live-detail"
fun getChzzkChannelId(channelId: String): String? {
val url = "https://api.chzzk.naver.com/polling/v3/channels/$channelId/live-status?includePlayerRecommendContent=false"
val request = Request.Builder()
.url(url)
.header("Content-Type", "application/json")
.get()
.build()
client.newCall(request).execute().use { response ->
try {
if(!response.isSuccessful) throw IOException("Unexpected code ${response.code}")
val body = response.body?.string()
val follow = gson.fromJson(body, object: TypeToken<IData<IStreamInfo>>() {})
val data = gson.fromJson(body, object: TypeToken<IData<LiveStatus?>>() {})
return follow
return data.content?.chatChannelId
} catch(e: Exception) {
throw e
}
}
}
}

View File

@ -0,0 +1,20 @@
package space.mori.chzzk_bot.common.utils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
val logger: Logger = LoggerFactory.getLogger("convertChzzkDateToLocalDateTime")
fun convertChzzkDateToLocalDateTime(chzzkDate: String): LocalDateTime? {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
return try {
LocalDateTime.parse(chzzkDate, formatter)
} catch(e: DateTimeParseException) {
logger.debug("Error to parsing date", e)
null
}
}

View File

@ -0,0 +1,9 @@
package space.mori.chzzk_bot.common.utils
fun getRandomString(length: Int): String {
val charPool = ('a'..'z') + ('0'..'9')
return (1..length)
.map { kotlin.random.Random.nextInt(0, charPool.size) }
.map(charPool::get)
.joinToString("")
}

View File

@ -0,0 +1,14 @@
package space.mori.chzzk_bot.common.utils
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
fun getUptime(streamOnTime: LocalDateTime): String {
val currentTime = LocalDateTime.now()
val hours = ChronoUnit.HOURS.between(streamOnTime, currentTime)
val minutes = ChronoUnit.MINUTES.between(streamOnTime?.plusHours(hours), currentTime)
val seconds = ChronoUnit.SECONDS.between(streamOnTime?.plusHours(hours)?.plusMinutes(minutes), currentTime)
return String.format("%02d:%02d:%02d", hours, minutes, seconds)
}

View File

@ -0,0 +1,115 @@
package space.mori.chzzk_bot.common.utils
import com.google.gson.JsonObject
import io.github.cdimascio.dotenv.dotenv
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.net.URLEncoder
data class YoutubeVideo(
val url: String,
val name: String,
val author: String,
val length: Int
)
val regex = ".*(?:youtu.be/|v/|u/\\w/|embed/|watch\\?v=|&v=|music\\.youtube\\.com/.*?\\?v=)([^#&?]*).*".toRegex()
val durationRegex = """PT(\d+H)?(\d+M)?(\d+S)?""".toRegex()
val dotenv = dotenv {
ignoreIfMissing = true
}
fun searchYoutube(query: String): String? {
val url = "https://youtube-search-results.p.rapidapi.com/youtube-search/?q=${URLEncoder.encode(query, "UTF-8")}"
val request = Request.Builder()
.url(url)
.addHeader("x-rapidapi-host", "youtube-search-results.p.rapidapi.com")
.addHeader("x-rapidapi-key", dotenv["RAPID_KEY"] ?: "")
.build()
OkHttpClient().newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body?.string()
val json = gson.fromJson(responseBody, JsonObject::class.java)
val videos = json.getAsJsonArray("videos")
val firstVideo = videos.get(0).asJsonObject
val videoId = firstVideo.get("id").asString
return videoId
}
}
fun getYoutubeVideoId(query: String): String? {
val matchResult = regex.find(query)
return if(matchResult == null) {
searchYoutube(query)
} else {
matchResult.groups[1]?.value
}
}
fun parseDuration(duration: String): Int {
val matchResult = durationRegex.find(duration)
val (hours, minutes, seconds) = matchResult?.destructured ?: return 0
val hourInSec = hours.dropLast(1).toIntOrNull()?.times(3600) ?: 0
val minutesInSec = minutes.dropLast(1).toIntOrNull()?.times(60) ?: 0
val totalSeconds = seconds.dropLast(1).toIntOrNull() ?: 0
return hourInSec + minutesInSec + totalSeconds
}
fun getYoutubeVideo(query: String): YoutubeVideo? {
val videoId = getYoutubeVideoId(query)
val api = HttpUrl.Builder()
.scheme("https")
.host("www.googleapis.com")
.addPathSegment("youtube")
.addPathSegment("v3")
.addPathSegment("videos")
.addQueryParameter("id", videoId)
.addQueryParameter("key", dotenv["YOUTUBE_API_KEY"])
.addQueryParameter("part", "snippet,contentDetails,status")
.build()
val request = Request.Builder()
.url(api)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw IOException("Unexpected code $response")
val responseBody = response.body?.string()
val json = gson.fromJson(responseBody, JsonObject::class.java)
val items = json.getAsJsonArray("items")
if (items == null || items.size() == 0) return null
val item = items[0].asJsonObject
val snippet = item.getAsJsonObject("snippet")
val contentDetail = item.getAsJsonObject("contentDetails")
val status = item.getAsJsonObject("status")
if (!status.get("embeddable").asBoolean) return null
val duration = contentDetail.get("duration").asString
val length = parseDuration(duration)
return YoutubeVideo(
"https://www.youtube.com/watch?v=$videoId",
snippet.get("title").asString,
snippet.get("channelTitle").asString,
length
)
}
}

View File

@ -1,6 +1,6 @@
kotlin.code.style=official
group = space.mori
version = 1.0.0
version = 1.2.0
org.gradle.jvmargs=-Dfile.encoding=UTF-8
org.gradle.console=plain

View File

@ -4,5 +4,11 @@ DB_URL=jdbc:mariadb://localhost:3306/chzzk
DB_USER=chzzk
DB_PASS=chzzk
RUN_AGENT=false
YOUTUBE_API_KEY=
RAPID_KEY=
HOST=http://localhost:8080
FRONTEND=http://localhost:3000
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
NID_AUT=
NID_SES=

View File

@ -14,6 +14,13 @@ pluginManagement {
mavenCentral()
gradlePluginPortal()
maven { url = uri("https://maven.covers1624.net") }
maven { url = uri("https://repo.spring.io/plugins-release/") }
maven { url = uri("https://jitpack.io") }
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
include("common")
include("chatbot")
include("webserver")

View File

@ -3,11 +3,17 @@ package space.mori.chzzk_bot
import io.github.cdimascio.dotenv.dotenv
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext.startKoin
import org.koin.dsl.module
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.discord.Discord
import space.mori.chzzk_bot.chzzk.Connector as ChzzkConnector
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.discord.Discord
import space.mori.chzzk_bot.chatbot.chzzk.Connector as ChzzkConnector
import space.mori.chzzk_bot.common.Connector
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.webserver.start
import space.mori.chzzk_bot.webserver.stop
import java.util.concurrent.TimeUnit
import kotlin.system.exitProcess
@ -16,16 +22,24 @@ val dotenv = dotenv {
}
val logger: Logger = LoggerFactory.getLogger("main")
val discord = Discord()
val connector = Connector
val chzzkConnector = ChzzkConnector
val chzzkHandler = ChzzkHandler
fun main(args: Array<String>) {
val dispatcher = module {
single { CoroutinesEventBus() }
}
startKoin {
modules(dispatcher)
}
val discord = Discord()
val connector = Connector
val chzzkConnector = ChzzkConnector
val chzzkHandler = ChzzkHandler
discord.enable()
chzzkHandler.enable()
chzzkHandler.runStreamInfo()
start()
if(dotenv.get("RUN_AGENT", "false").toBoolean()) {
runBlocking {
@ -36,6 +50,8 @@ fun main(args: Array<String>) {
Runtime.getRuntime().addShutdownHook(Thread {
logger.info("Shutting down...")
stop()
chzzkHandler.stopStreamInfo()
chzzkHandler.disable()
discord.disable()

View File

@ -1,169 +0,0 @@
package space.mori.chzzk_bot.chzzk
import net.dv8tion.jda.api.EmbedBuilder
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chzzk.Connector.chzzk
import space.mori.chzzk_bot.discord
import space.mori.chzzk_bot.models.User
import space.mori.chzzk_bot.services.UserService
import xyz.r2turntrue.chzzk4j.chat.ChatEventListener
import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel
import java.lang.Exception
import java.net.SocketTimeoutException
import java.time.Instant
object ChzzkHandler {
private val handlers = mutableListOf<UserHandler>()
private val logger = LoggerFactory.getLogger(this::class.java)
@Volatile private var running: Boolean = false
internal fun addUser(chzzkChannel: ChzzkChannel, user: User) {
handlers.add(UserHandler(chzzkChannel, logger, user))
}
internal fun enable() {
UserService.getAllUsers().map {
chzzk.getChannel(it.token)?.let { token -> addUser(token, it)}
}
}
internal fun disable() {
handlers.forEach { handler ->
handler.disable()
}
}
internal fun reloadCommand(chzzkChannel: ChzzkChannel) {
val handler = handlers.firstOrNull { it.channel.channelId == chzzkChannel.channelId }
if (handler != null)
handler.reloadCommand()
else
throw RuntimeException("${chzzkChannel.channelName} doesn't have handler")
}
internal fun reloadUser(chzzkChannel: ChzzkChannel, user: User) {
val handler = handlers.firstOrNull { it.channel.channelId == chzzkChannel.channelId }
if (handler != null)
handler.reloadUser(user)
else
throw RuntimeException("${chzzkChannel.channelName} doesn't have handler")
}
internal fun runStreamInfo() {
running = true
val thread = Thread({
while(running) {
handlers.forEach {
if (!running) return@forEach
try {
val streamInfo = getStreamInfo(it.channel.channelId)
if (streamInfo.content.status == "OPEN" && !it.isActive) it.isActive(true, streamInfo)
if (streamInfo.content.status == "CLOSE" && it.isActive) it.isActive(false, streamInfo)
} catch(e: SocketTimeoutException) {
logger.info("Timeout: ${it.channel.channelName} / ${e.stackTraceToString()}")
} catch (e: Exception) {
logger.info("Exception: ${it.channel.channelName} / ${e.stackTraceToString()}")
} finally {
Thread.sleep(5000)
}
}
Thread.sleep(60000)
}
}, "Chzzk-StreamInfo")
thread.start()
}
internal fun stopStreamInfo() {
running = false
}
}
class UserHandler(
val channel: ChzzkChannel,
private val logger: Logger,
private var user: User,
private var _isActive: Boolean = false
) {
private lateinit var messageHandler: MessageHandler
private var listener: ChzzkChat = chzzk.chat(channel.channelId)
.withAutoReconnect(true)
.withChatListener(object : ChatEventListener {
override fun onConnect(chat: ChzzkChat, isReconnecting: Boolean) {
logger.info("ChzzkChat connected. ${channel.channelName} - ${channel.channelId} / reconnected: $isReconnecting")
messageHandler = MessageHandler(channel, logger, chat)
}
override fun onError(ex: Exception) {
logger.info("ChzzkChat error. ${channel.channelName} - ${channel.channelId}")
logger.debug(ex.stackTraceToString())
}
override fun onChat(msg: ChatMessage) {
if(!_isActive) return
messageHandler.handle(msg, user)
}
override fun onConnectionClosed(code: Int, reason: String?, remote: Boolean, tryingToReconnect: Boolean) {
logger.info("ChzzkChat closed. ${channel.channelName} - ${channel.channelId}")
logger.info("Reason: $reason / $tryingToReconnect")
}
})
.build()
internal fun disable() {
listener.closeAsync()
}
internal fun reloadCommand() {
messageHandler.reloadCommand()
}
internal fun reloadUser(user: User) {
this.user = user
}
internal val isActive: Boolean
get() = _isActive
internal fun isActive(value: Boolean, status: IData<IStreamInfo>) {
_isActive = value
if(value) {
logger.info("${user.username} is live.")
logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}")
listener.connectAsync()
if(user.liveAlertMessage != "" && user.liveAlertGuild != null && user.liveAlertChannel != null) {
val channel = discord.getChannel(user.liveAlertGuild!!, user.liveAlertChannel!!) ?: throw RuntimeException("${user.liveAlertChannel} is not valid.")
val embed = EmbedBuilder()
embed.setTitle(status.content.liveTitle, "https://chzzk.naver.com/live/${user.token}")
embed.setDescription("${user.username} 님이 방송을 시작했습니다.")
embed.setUrl(status.content.channel.channelImageUrl)
embed.setTimestamp(Instant.now())
embed.setAuthor(user.username, "https://chzzk.naver.com/live/${user.token}", status.content.channel.channelImageUrl)
embed.addField("카테고리", status.content.liveCategoryValue, true)
embed.addField("태그", status.content.tags.joinToString(", "), true)
embed.setImage(status.content.liveImageUrl.replace("{type}", "1080"))
channel.sendMessage(
MessageCreateBuilder()
.setContent(user.liveAlertMessage)
.setEmbeds(embed.build())
.build()
).queue()
listener.sendChat("${user.username} 님의 방송이 감지되었습니다.")
}
} else {
logger.info("${user.username} is offline.")
listener.closeAsync()
}
}
}

View File

@ -1,20 +0,0 @@
package space.mori.chzzk_bot.chzzk
import org.slf4j.LoggerFactory
import xyz.r2turntrue.chzzk4j.Chzzk
import xyz.r2turntrue.chzzk4j.ChzzkBuilder
import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel
import space.mori.chzzk_bot.dotenv
object Connector {
val chzzk: Chzzk = ChzzkBuilder()
.withAuthorization(dotenv["NID_AUT"], dotenv["NID_SES"])
.build()
private val logger = LoggerFactory.getLogger(this::class.java)
fun getChannel(channelId: String): ChzzkChannel? = chzzk.getChannel(channelId)
init {
logger.info("chzzk logged: ${chzzk.isLoggedIn} / ${chzzk.loggedUser?.nickname ?: "----"}")
}
}

View File

@ -1,193 +0,0 @@
package space.mori.chzzk_bot.chzzk
import org.slf4j.Logger
import space.mori.chzzk_bot.models.User
import space.mori.chzzk_bot.services.CommandService
import space.mori.chzzk_bot.services.CounterService
import space.mori.chzzk_bot.services.UserService
import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
import xyz.r2turntrue.chzzk4j.types.channel.ChzzkChannel
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
class MessageHandler(
private val channel: ChzzkChannel,
private val logger: Logger,
private val listener: ChzzkChat
) {
private val commands = mutableMapOf<String, (msg: ChatMessage, user: User) -> Unit>()
private val counterPattern = Regex("<counter:([^>]+)>")
private val personalCounterPattern = Regex("<counter_personal:([^>]+)>")
private val dailyCounterPattern = Regex("<daily_counter:([^>]+)>")
private val namePattern = Regex("<name>")
private val followPattern = Regex("<following>")
private val daysPattern = """<days:(\d{4})-(\d{2})-(\d{2})>""".toRegex()
init {
reloadCommand()
}
internal fun reloadCommand() {
val user = UserService.getUser(channel.channelId)
?: throw RuntimeException("User not found. it's bug? ${channel.channelName} - ${channel.channelId}")
val commands = CommandService.getCommands(user)
val manageCommands = mapOf("!명령어추가" to this::manageAddCommand, "!명령어삭제" to this::manageRemoveCommand, "!명령어수정" to this::manageUpdateCommand)
manageCommands.forEach { (commandName, command) ->
this.commands[commandName] = command
}
commands.map {
this.commands.put(it.command.lowercase()) { msg, user ->
logger.debug("${channel.channelName} - ${it.command} - ${it.content}/${it.failContent}")
val result = replaceCounters(Pair(it.content, it.failContent), user, msg, listener, msg.profile?.nickname ?: "")
listener.sendChat(result)
}
}
}
private fun manageAddCommand(msg: ChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 명령어를 추가할 수 있습니다.")
return
}
val parts = msg.content.split(" ", limit = 3)
if (parts.size < 3) {
listener.sendChat("명령어 추가 형식은 '!명령어추가 명령어 내용'입니다.")
return
}
if (commands.containsKey(parts[1])) {
listener.sendChat("${parts[1]} 명령어는 이미 있는 명령어입니다.")
return
}
val command = parts[1]
val content = parts[2]
CommandService.saveCommand(user, command, content, "")
listener.sendChat("명령어 '$command' 추가되었습니다.")
}
private fun manageUpdateCommand(msg: ChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 명령어를 추가할 수 있습니다.")
return
}
val parts = msg.content.split(" ", limit = 3)
if (parts.size < 3) {
listener.sendChat("명령어 수정 형식은 '!명령어수정 명령어 내용'입니다.")
return
}
if (!commands.containsKey(parts[1])) {
listener.sendChat("${parts[1]} 명령어는 없는 명령어입니다.")
return
}
val command = parts[1]
val content = parts[2]
CommandService.updateCommand(user, command, content, "")
listener.sendChat("명령어 '$command' 수정되었습니다.")
}
private fun manageRemoveCommand(msg: ChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 명령어를 삭제할 수 있습니다.")
return
}
val parts = msg.content.split(" ", limit = 2)
if (parts.size < 2) {
listener.sendChat("명령어 삭제 형식은 '!명령어삭제 명령어'입니다.")
return
}
val command = parts[1]
CommandService.removeCommand(user, command)
listener.sendChat("명령어 '$command' 삭제되었습니다.")
}
internal fun handle(msg: ChatMessage, user: User) {
val commandKey = msg.content.split(' ')[0]
commands[commandKey.lowercase()]?.let { it(msg, user) }
}
private fun replaceCounters(chat: Pair<String, String>, user: User, msg: ChatMessage, listener: ChzzkChat, userName: String): String {
var result = chat.first
var isFail = false
// Replace dailyCounterPattern
result = dailyCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1]
val dailyCounter = CounterService.getDailyCounterValue(name, msg.userId, user)
if (dailyCounter.second) {
CounterService.updateDailyCounterValue(name, msg.userId, 1, user).first.toString()
} else {
isFail = true
dailyCounter.first.toString()
}
}
// Handle fail case
if (isFail && chat.second.isNotEmpty()) {
result = chat.second
result = dailyCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1]
val dailyCounter = CounterService.getDailyCounterValue(name, msg.userId, user)
dailyCounter.first.toString()
}
}
// Replace followPattern
result = followPattern.replace(result) { matchResult ->
try {
val followingDate = getFollowDate(listener.chatId, msg.userId)
.content.streamingProperty.following?.followDate
val period = followingDate?.let {
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
val pastDate = LocalDateTime.parse(it, formatter)
val today = LocalDateTime.now()
ChronoUnit.DAYS.between(pastDate, today)
} ?: 0
period.toString()
} catch (e: Exception) {
logger.error(e.message)
"0"
}
}
// Replace daysPattern
result = daysPattern.replace(result) { matchResult ->
val (year, month, day) = matchResult.destructured
val pastDate = LocalDateTime.of(year.toInt(), month.toInt(), day.toInt(), 0, 0, 0)
val today = LocalDateTime.now()
val daysBetween = ChronoUnit.DAYS.between(pastDate, today)
daysBetween.toString()
}
// Replace counterPattern
result = counterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1]
CounterService.updateCounterValue(name, 1, user).toString()
}
// Replace personalCounterPattern
result = personalCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1]
CounterService.updatePersonalCounterValue(name, msg.userId, 1, user).toString()
}
// Replace namePattern
result = namePattern.replace(result, userName)
return result
}
}

View File

@ -1,85 +0,0 @@
package space.mori.chzzk_bot.discord
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.JDABuilder
import net.dv8tion.jda.api.entities.Activity
import net.dv8tion.jda.api.entities.Guild
import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.hooks.ListenerAdapter
import space.mori.chzzk_bot.dotenv
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.discord.commands.*
import space.mori.chzzk_bot.services.ManagerService
class Discord: ListenerAdapter() {
private lateinit var bot: JDA
private var guild: Guild? = null
private val logger = LoggerFactory.getLogger(this::class.java)
private val commands = listOf(
AddCommand,
AlertCommand,
PingCommand,
RegisterCommand,
RemoveCommand,
UpdateCommand,
AddManagerCommand,
ListManagerCommand,
RemoveManagerCommand,
)
override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) {
event.deferReply().queue()
val handler = commands.find { it.name == event.name }
logger.debug("Handler: ${handler?.name ?: "undefined"} command")
handler?.run(event, bot)
}
override fun onGuildMemberRemove(event: GuildMemberRemoveEvent) {
event.member?.let { ManagerService.deleteManager(event.guild.idLong, it.idLong) }
}
internal fun enable() {
val thread = Thread {
try {
bot = JDABuilder.createDefault(dotenv["DISCORD_TOKEN"])
.setActivity(Activity.playing("치지직 보는중"))
.addEventListeners(this)
.build().awaitReady()
guild = bot.getGuildById(dotenv["GUILD_ID"])
bot.updateCommands()
.addCommands(* commands.map { it.command }.toTypedArray())
.onSuccess {
logger.info("Command update success!")
logger.debug("Command list: ${commands.joinToString("/ ") { it.name }}")
}
.queue()
if (guild == null) {
logger.info("No guild found!")
this.disable()
}
} catch (e: Exception) {
logger.info("Could not enable Discord!")
logger.debug(e.stackTraceToString())
}
}
thread.start()
}
internal fun disable() {
try {
bot.shutdown()
} catch(e: Exception) {
logger.info("Error while shutting down Discord!")
logger.debug(e.stackTraceToString())
}
}
internal fun getChannel(guildId: Long, channelId: Long) =
bot.getGuildById(guildId)?.getTextChannelById(channelId)
}

View File

@ -1,65 +0,0 @@
package space.mori.chzzk_bot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chzzk.Connector
import space.mori.chzzk_bot.discord.CommandInterface
import space.mori.chzzk_bot.services.CommandService
import space.mori.chzzk_bot.services.ManagerService
import space.mori.chzzk_bot.services.UserService
object AddCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "add"
override val command = Commands.slash(name, "명령어를 추가합니다.")
.addOptions(OptionData(OptionType.STRING, "label", "작동할 명령어를 입력하세요.", true))
.addOptions(OptionData(OptionType.STRING, "content", "표시될 텍스트를 입력하세요.", true))
.addOptions(OptionData(OptionType.STRING, "fail_content", "카운터 업데이트 실패시 표시될 텍스트를 입력하세요.", false))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val label = event.getOption("label")?.asString
val content = event.getOption("content")?.asString
val failContent = event.getOption("fail_content")?.asString
if(label == null || content == null) {
event.hook.sendMessage("명령어와 텍스트는 필수 입력입니다.").queue()
return
}
var user = UserService.getUser(event.user.idLong)
val manager = event.guild?.idLong?.let { ManagerService.getUser(it, event.user.idLong) }
if(user == null && manager == null) {
event.hook.sendMessage("당신은 이 명령어를 사용할 수 없습니다.").queue()
return
}
if (user == null) {
user = manager!!.user
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName)
}
val commands = CommandService.getCommands(user)
if (commands.any { it.command == label }) {
event.hook.sendMessage("$label 명령어는 이미 있습니다! 업데이트 명령어를 써주세요.").queue()
return
}
val chzzkChannel = Connector.getChannel(user.token)
try {
CommandService.saveCommand(user, label, content, failContent ?: "")
try {
ChzzkHandler.reloadCommand(chzzkChannel!!)
} catch (_: Exception) {}
event.hook.sendMessage("등록이 완료되었습니다. $label = $content/$failContent").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -1,47 +0,0 @@
package space.mori.chzzk_bot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.discord.CommandInterface
import space.mori.chzzk_bot.services.ManagerService
import space.mori.chzzk_bot.services.UserService
object AddManagerCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "addmanager"
override val command = Commands.slash(name, "매니저를 추가합니다.")
.addOptions(OptionData(OptionType.USER, "user", "추가할 유저를 선택하세요.", true))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val manager = event.getOption("user")?.asUser
if(manager == null) {
event.hook.sendMessage("유저는 필수사항입니다.").queue()
return
}
if(manager.idLong == event.user.idLong) {
event.hook.sendMessage("자신은 매니저로 설정할 수 없습니다.").queue()
return
}
val user = UserService.getUser(event.user.idLong)
if(user == null) {
event.hook.sendMessage("치지직 계정을 찾을 수 없습니다.").queue()
return
}
try {
ManagerService.saveManager(user, manager.idLong, manager.effectiveName)
if(user.liveAlertGuild == null)
UserService.updateLiveAlert(user.id.value, event.guild!!.idLong, event.channelIdLong, "")
event.hook.sendMessage("등록이 완료되었습니다. ${manager.effectiveName}").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -1,51 +0,0 @@
package space.mori.chzzk_bot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chzzk.Connector
import space.mori.chzzk_bot.discord.CommandInterface
import space.mori.chzzk_bot.services.ManagerService
import space.mori.chzzk_bot.services.UserService
object AlertCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "alert"
override val command = Commands.slash(name, "명령어를 추가합니다.")
.addOptions(OptionData(OptionType.CHANNEL, "channel", "알림을 보낼 채널을 입력하세요."))
.addOptions(OptionData(OptionType.STRING, "content", "표시될 텍스트를 입력하세요. 비워두면 알람이 취소됩니다."))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val channel = event.getOption("channel")?.asChannel
val content = event.getOption("content")?.asString
var user = UserService.getUser(event.user.idLong)
val manager = event.guild?.idLong?.let { ManagerService.getUser(it, event.user.idLong) }
if(user == null && manager == null) {
event.hook.sendMessage("당신은 이 명령어를 사용할 수 없습니다.").queue()
return
}
if (user == null) {
user = manager!!.user
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName)
}
val chzzkChannel = Connector.getChannel(user.token)
try {
val newUser = UserService.updateLiveAlert(user.id.value, channel?.guild?.idLong ?: 0L, channel?.idLong ?: 0L, content ?: "")
try {
ChzzkHandler.reloadUser(chzzkChannel!!, newUser)
} catch (_: Exception) {}
event.hook.sendMessage("업데이트가 완료되었습니다.").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -1,44 +0,0 @@
package space.mori.chzzk_bot.discord.commands
import net.dv8tion.jda.api.EmbedBuilder
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.build.Commands
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.discord.CommandInterface
import space.mori.chzzk_bot.services.ManagerService
import space.mori.chzzk_bot.services.UserService
object ListManagerCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "listmanager"
override val command = Commands.slash(name, "매니저 목록을 확인합니다.")
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
try {
val managers = event.guild?.idLong?.let { ManagerService.getAllUsers(it) }
if(managers == null) {
event.channel.sendMessage("여기에서는 사용할 수 없습니다.")
return
}
val user = UserService.getUserWithGuildId(event.guild!!.idLong)
val embed = EmbedBuilder()
embed.setTitle("${user!!.username} 매니저 목록")
embed.setDescription("매니저 목록입니다.")
var idx = 1
managers.forEach {
embed.addField("${idx++}", it.lastUserName ?: it.managerId.toString(), true)
}
event.channel.sendMessageEmbeds(embed.build()).queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -1,49 +0,0 @@
package space.mori.chzzk_bot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chzzk.Connector
import space.mori.chzzk_bot.discord.CommandInterface
import space.mori.chzzk_bot.services.UserService
object RegisterCommand: CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name = "register"
override val command = Commands.slash(name, "치지직 계정을 등록합니다.")
.addOptions(
OptionData(
OptionType.STRING,
"chzzk_id",
"36da10b7c35800f298e9c565a396bafd 형식으로 입력해주세요.",
true
)
)
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val chzzkID = event.getOption("chzzk_id")?.asString
if(chzzkID == null) {
event.hook.sendMessage("치지직 계정은 필수 입력입니다.").queue()
return
}
val chzzkChannel = Connector.getChannel(chzzkID)
if (chzzkChannel == null) {
event.hook.sendMessage("치지직 계정을 찾을 수 없습니다.").queue()
return
}
try {
val user = UserService.saveUser(chzzkChannel.channelName, chzzkChannel.channelId, event.user.idLong)
event.hook.sendMessage("등록이 완료되었습니다. `${chzzkChannel.channelId}` - `${chzzkChannel.channelName}`")
ChzzkHandler.addUser(chzzkChannel, user)
} catch(e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -1,55 +0,0 @@
package space.mori.chzzk_bot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chzzk.Connector
import space.mori.chzzk_bot.discord.CommandInterface
import space.mori.chzzk_bot.services.CommandService
import space.mori.chzzk_bot.services.ManagerService
import space.mori.chzzk_bot.services.UserService
object RemoveCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "remove"
override val command = Commands.slash(name, "명령어를 삭제합니다.")
.addOptions(OptionData(OptionType.STRING, "label", "삭제할 명령어를 입력하세요.", true))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val label = event.getOption("label")?.asString
if(label == null) {
event.hook.sendMessage("명령어는 필수 입력입니다.").queue()
return
}
var user = UserService.getUser(event.user.idLong)
val manager = event.guild?.idLong?.let { ManagerService.getUser(it, event.user.idLong) }
if(user == null && manager == null) {
event.hook.sendMessage("당신은 이 명령어를 사용할 수 없습니다.").queue()
return
}
if (user == null) {
user = manager!!.user
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName)
}
val chzzkChannel = Connector.getChannel(user.token)
try {
CommandService.removeCommand(user, label)
try {
ChzzkHandler.reloadCommand(chzzkChannel!!)
} catch (_: Exception) {}
event.hook.sendMessage("삭제가 완료되었습니다. $label").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -1,49 +0,0 @@
package space.mori.chzzk_bot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.discord.CommandInterface
import space.mori.chzzk_bot.services.ManagerService
import space.mori.chzzk_bot.services.UserService
object RemoveManagerCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "removemanager"
override val command = Commands.slash(name, "매니저를 삭제합니다.")
.addOptions(OptionData(OptionType.USER, "user", "삭제할 유저를 선택하세요.", true))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val manager = event.getOption("user")?.asUser
if(manager == null) {
event.hook.sendMessage("유저는 필수사항입니다.").queue()
return
}
if(manager.idLong == event.user.idLong) {
event.hook.sendMessage("자신은 매니저로 설정할 수 없습니다.").queue()
return
}
val user = UserService.getUser(event.user.idLong)
if(user == null) {
event.hook.sendMessage("치지직 계정을 찾을 수 없습니다.").queue()
return
}
if(ManagerService.getUser(user.liveAlertGuild ?: 0L, manager.idLong) == null) {
event.hook.sendMessage("${manager.name}은 매니저가 아닙니다.")
}
try {
ManagerService.deleteManager(user, manager.idLong)
event.hook.sendMessage("삭제가 완료되었습니다. ${manager.effectiveName}").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -1,57 +0,0 @@
package space.mori.chzzk_bot.discord.commands
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chzzk.Connector
import space.mori.chzzk_bot.discord.CommandInterface
import space.mori.chzzk_bot.services.CommandService
import space.mori.chzzk_bot.services.ManagerService
import space.mori.chzzk_bot.services.UserService
object UpdateCommand : CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name: String = "update"
override val command = Commands.slash(name, "명령어를 수정합니다.")
.addOptions(OptionData(OptionType.STRING, "label", "수정할 명령어를 입력하세요.", true))
.addOptions(OptionData(OptionType.STRING, "content", "표시될 텍스트를 입력하세요.", true))
.addOptions(OptionData(OptionType.STRING, "fail_content", "카운터 업데이트 실패시 표시될 텍스트를 입력하세요.", false))
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val label = event.getOption("label")?.asString
val content = event.getOption("content")?.asString
val failContent = event.getOption("fail_content")?.asString
if(label == null || content == null) {
event.hook.sendMessage("명령어와 텍스트는 필수 입력입니다.").queue()
return
}
var user = UserService.getUser(event.user.idLong)
val manager = event.guild?.idLong?.let { ManagerService.getUser(it, event.user.idLong) }
if(user == null && manager == null) {
event.hook.sendMessage("당신은 이 명령어를 사용할 수 없습니다.").queue()
return
}
if (user == null) {
user = manager!!.user
ManagerService.updateManager(user, event.user.idLong, event.user.effectiveName)
}
val chzzkChannel = Connector.getChannel(user.token)
try {
CommandService.updateCommand(user, label, content, failContent ?: "")
chzzkChannel?.let { ChzzkHandler.reloadCommand(it) }
event.hook.sendMessage("등록이 완료되었습니다. $label = $content").queue()
} catch (e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@ -1,22 +0,0 @@
package space.mori.chzzk_bot.models
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.dao.id.IntIdTable
object Managers: IntIdTable("manager") {
val user = reference("user", Users)
val managerId = long("manager_id")
val discordGuildId = long("discord_guild_id")
var lastUserName = varchar("last_user_name", 255).nullable()
}
class Manager(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<Manager>(Managers)
var user by User referencedOn Managers.user
var managerId by Managers.managerId
var discordGuildId by Managers.discordGuildId
var lastUserName by Managers.lastUserName
}

View File

@ -1,70 +0,0 @@
package space.mori.chzzk_bot.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.models.Manager
import space.mori.chzzk_bot.models.Managers
import space.mori.chzzk_bot.models.User
object ManagerService {
fun saveManager(user: User, discordId: Long, name: String): Manager {
if (user.liveAlertGuild == null)
throw RuntimeException("${user.username} has no liveAlertGuild")
return transaction {
Manager.new {
this.user = user
this.discordGuildId = user.liveAlertGuild!!
this.managerId = discordId
this.lastUserName = name
}
}
}
fun updateManager(user: User, discordId: Long, name: String): Manager {
if (user.liveAlertGuild == null)
throw RuntimeException("${user.username} has no liveAlertGuild")
val manager = getUser(user.liveAlertGuild!!, discordId)
if (manager == null)
throw RuntimeException("$name isn't manager.")
manager.lastUserName = name
return manager
}
fun getUser(guildId: Long, discordId: Long): Manager? {
return transaction {
val manager = Manager.find(
(Managers.discordGuildId eq guildId) and (Managers.managerId eq discordId)
)
manager.firstOrNull()
}
}
fun getAllUsers(guildId: Long): List<Manager> {
return transaction {
Manager.find(Managers.discordGuildId eq guildId).toList()
}
}
fun deleteManager(user: User, discordId: Long): Manager {
if (user.liveAlertGuild == null)
throw RuntimeException("${user.username} has no liveAlertGuild")
return deleteManager(user.liveAlertGuild!!, discordId)
}
fun deleteManager(guildId: Long, discordId: Long): Manager {
return transaction {
val managerRow = Manager.find((Managers.discordGuildId eq guildId) and (Managers.managerId eq discordId)).firstOrNull()
managerRow ?: throw RuntimeException("Manager not found! $discordId")
managerRow.delete()
managerRow
}
}
}

View File

@ -1,70 +0,0 @@
package space.mori.chzzk_bot.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import space.mori.chzzk_bot.models.User
import space.mori.chzzk_bot.models.Users
object UserService {
fun saveUser(username: String, token: String, discordID: Long): User {
return transaction {
User.new {
this.username = username
this.token = token
this.discord = discordID
}
}
}
fun getUser(id: Int): User? {
return transaction {
User.findById(id)
}
}
fun getUser(discordID: Long): User? {
return transaction {
val users = User.find(Users.discord eq discordID)
users.firstOrNull()
}
}
fun getUser(chzzkID: String): User? {
return transaction {
val users = User.find(Users.token eq chzzkID)
users.firstOrNull()
}
}
fun getUserWithGuildId(discordGuildId: Long): User? {
return transaction {
val users = User.find(Users.liveAlertGuild eq discordGuildId)
users.firstOrNull()
}
}
fun getAllUsers(): List<User> {
return transaction {
User.all().toList()
}
}
fun updateLiveAlert(id: Int, guildId: Long, channelId: Long, alertMessage: String?): User {
return transaction {
val updated = Users.update({ Users.id eq id }) {
it[liveAlertGuild] = guildId
it[liveAlertChannel] = channelId
it[liveAlertMessage] = alertMessage ?: ""
}
if(updated == 0) throw RuntimeException("User not found! $id")
val users = User.find { Users.id eq id }
return@transaction users.first()
}
}
}

View File

@ -0,0 +1,56 @@
plugins {
kotlin("jvm")
kotlin("plugin.serialization").version("2.0.0")
}
group = project.rootProject.group
version = project.rootProject.version
repositories {
mavenCentral()
}
val ktorVersion = "3.1.3"
dependencies {
implementation("io.ktor:ktor-server-core:$ktorVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion")
implementation("io.ktor:ktor-server-websockets:$ktorVersion")
implementation("io.ktor:ktor-server-swagger:$ktorVersion")
implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-server-cors:$ktorVersion")
implementation("io.ktor:ktor-server-swagger:$ktorVersion")
implementation("io.ktor:ktor-server-auth:$ktorVersion")
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-cio:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.swagger.codegen.v3:swagger-codegen-generators:1.0.54")
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-reflect
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.21")
// https://mvnrepository.com/artifact/io.insert-koin/koin-core
implementation("io.insert-koin:koin-core:4.0.0")
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.12")
// https://mvnrepository.com/artifact/io.github.cdimascio/dotenv-kotlin
implementation("io.github.cdimascio:dotenv-kotlin:6.4.2")
implementation(project(":common"))
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
kotlin {
jvmToolchain(21)
}

View File

@ -0,0 +1,447 @@
package space.mori.chzzk_bot.webserver
import applicationHttpClient
import io.github.cdimascio.dotenv.dotenv
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.swagger.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import io.ktor.server.websocket.*
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.common.events.UserRegisterEvent
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.routes.*
import space.mori.chzzk_bot.webserver.utils.DiscordRatelimits
import java.math.BigInteger
import java.security.SecureRandom
import java.time.Duration
import kotlin.getValue
import kotlin.time.toKotlinDuration
val dotenv = dotenv {
ignoreIfMissing = true
}
val redirects = mutableMapOf<String, String>()
val server = embeddedServer(Netty, port = 8080, ) {
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15).toKotlinDuration()
timeout = Duration.ofSeconds(100).toKotlinDuration()
maxFrameSize = Long.MAX_VALUE
masking = false
contentConverter = KotlinxWebsocketSerializationConverter(Json)
}
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
})
}
install(Sessions) {
cookie<UserSession>("user_session", storage = MariadbSessionStorage()) {}
}
install(Authentication) {
oauth("auth-oauth-discord") {
urlProvider = { "${dotenv["HOST"]}/auth/callback/discord" }
providerLookup = { OAuthServerSettings.OAuth2ServerSettings(
name = "discord",
authorizeUrl = "https://discord.com/oauth2/authorize",
accessTokenUrl = "https://discord.com/api/oauth2/token",
clientId = dotenv["DISCORD_CLIENT_ID"],
clientSecret = dotenv["DISCORD_CLIENT_SECRET"],
requestMethod = HttpMethod.Post,
defaultScopes = listOf(),
extraAuthParameters = listOf(
Pair("permissions", "826781943872"),
Pair("response_type", "code"),
Pair("integration_type", "0"),
Pair("scope", "guilds bot identify")
),
onStateCreated = { call, state ->
call.request.queryParameters["redirectUrl"]?.let {
redirects[state] = it
}
}
)}
client = applicationHttpClient
}
}
routing {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
route("/auth") {
// discord login
authenticate("auth-oauth-discord") {
get("/login/discord") {
}
get("/callback/discord") {
try {
val principal = call.principal<OAuthAccessTokenResponse.OAuth2>()
val session = call.sessions.get<UserSession>()
val user = session?.id?.let { UserService.getUser(it) }
if(principal != null && session != null && user != null) {
try {
val accessToken = principal.accessToken
val userInfo = getDiscordUser(accessToken)
val guilds = getUserGuilds(accessToken)
userInfo?.user?.id?.toLong()?.let { id -> UserService.updateUser(user, id) }
call.sessions.set(UserSession(
session.state,
session.id,
guilds.filter {
it.owner
}.map { it.id }
))
redirects[principal.state]?.let { redirect ->
call.respondRedirect(redirect)
return@get
}
call.respondRedirect(getFrontendURL(""))
} catch(e: Exception) {
println(e.toString())
call.respondRedirect(getFrontendURL(""))
}
} else {
call.respondRedirect(getFrontendURL(""))
}
} catch(e: Exception) {
println(e.stackTrace)
}
}
}
// naver login
get("/login") {
val state = generateSecureRandomState()
// 세션에 상태 값 저장
call.sessions.set(UserSession(
state,
"",
listOf(),
))
// OAuth 제공자의 인증 URL 구성
val authUrl = URLBuilder("https://chzzk.naver.com/account-interlock").apply {
parameters.append("clientId", dotenv["NAVER_CLIENT_ID"]) // 비표준 파라미터 이름
parameters.append("redirectUri", "${dotenv["HOST"]}/auth/callback")
parameters.append("state", state)
// 추가적인 파라미터가 필요하면 여기에 추가
}.build().toString()
// 사용자에게 인증 페이지로 리다이렉트
call.respondRedirect(authUrl)
}
get("/callback") {
val receivedState = call.parameters["state"]
val code = call.parameters["code"]
// 세션에서 상태 값 가져오기
val session = call.sessions.get<UserSession>()
if (session == null || session.state != receivedState) {
call.respond(HttpStatusCode.BadRequest, "Invalid state parameter")
return@get
}
if (code == null) {
call.respond(HttpStatusCode.BadRequest, "Missing code parameter")
return@get
}
try {
// Access Token 요청
val tokenRequest = TokenRequest(
grantType = "authorization_code",
state = session.state,
code = code,
clientId = dotenv["NAVER_CLIENT_ID"],
clientSecret = dotenv["NAVER_CLIENT_SECRET"]
)
val response = applicationHttpClient.post("https://openapi.chzzk.naver.com/auth/v1/token") {
contentType(ContentType.Application.Json)
setBody(tokenRequest)
}
val tokenResponse = response.body<TokenResponse>()
if(tokenResponse.content == null) {
call.respond(HttpStatusCode.InternalServerError, "Failed to obtain access token")
return@get
}
// Access Token 사용: 예를 들어, 사용자 정보 요청
val userInfo = getChzzkUser(tokenResponse.content.accessToken)
if(userInfo.content != null) {
var user = UserService.getUser(userInfo.content.channelId)
if(user == null) {
user = UserService.saveUser(userInfo.content.channelName , userInfo.content.channelId)
}
call.sessions.set(
UserSession(
session.state,
userInfo.content.channelId,
listOf()
)
)
UserService.setRefreshToken(user,
tokenResponse.content.accessToken,
tokenResponse.content.refreshToken ?: ""
)
dispatcher.post(UserRegisterEvent(user.token))
call.respondRedirect(getFrontendURL(""))
}
} catch (e: Exception) {
e.printStackTrace()
call.respond(HttpStatusCode.InternalServerError, "Failed to obtain access token")
}
}
// common: logout
get("/logout") {
call.sessions.clear<UserSession>()
call.response.status(HttpStatusCode.OK)
return@get
}
}
apiRoutes()
apiSongRoutes()
apiCommandRoutes()
apiTimerRoutes()
apiDiscordRoutes()
wsTimerRoutes()
wsSongRoutes()
wsSongListRoutes()
swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") {
options {
version = "1.2.0"
}
}
}
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Get)
allowHost(dotenv["FRONTEND"] ?: "localhost:3000", schemes=listOf("https"))
allowCredentials = true
allowNonSimpleContentTypes = true
}
}
fun start() {
server.start(wait = true)
}
fun stop() {
server.stop()
}
fun getFrontendURL(path: String)
= "${if(dotenv["FRONTEND_HTTPS"].toBoolean()) "https://" else "http://" }${dotenv["FRONTEND"]}${path}"
@Serializable
data class UserSession(
val state: String,
val id: String,
val discordGuildList: List<String>,
)
@Serializable
data class TokenRequest(
val grantType: String,
val state: String,
val code: String,
val clientId: String,
val clientSecret: String
)
@Serializable
data class TokenResponse(
val code: Int,
val message: String?,
val content: TokenResponseBody?
)
@Serializable
data class TokenResponseBody(
val accessToken: String,
val tokenType: String,
val expiresIn: Int,
val refreshToken: String? = null
)
@Serializable
data class DiscordMeAPI(
val application: DiscordApplicationAPI,
val scopes: List<String>,
val user: DiscordUserAPI
)
@Serializable
data class DiscordApplicationAPI(
val id: String,
val name: String,
val icon: String,
val description: String,
val hook: Boolean,
val bot_public: Boolean,
val bot_require_code_grant: Boolean,
val verify_key: String
)
@Serializable
data class DiscordUserAPI(
val id: String,
val username: String,
val avatar: String,
val discriminator: String,
val global_name: String,
val public_flags: Int
)
@Serializable
data class DiscordGuildListAPI(
val id: String,
val name: String,
val icon: String?,
val banner: String?,
val owner: Boolean,
val permissions: Int,
val features: List<String>,
val roles: List<GuildRole>?
)
@Serializable
data class GuildRole(
val id: String,
val name: String,
val color: Int,
val mentionable: Boolean,
)
enum class ChannelType(val value: Int) {
GUILD_TEXT(0),
DM(1),
GUILD_VOICE(2),
GROUP_DM(3),
GUILD_CATEGORY(4),
GUILD_ANNOUNCEMENT(5),
ANNOUNCEMENT_THREAD(10),
PUBLIC_THREAD(11),
PRIVATE_THREAD(12),
GUILD_STAGE_VOICE(13),
GUILD_DIRECTORY(14),
GUILD_FORUM(15),
GUILD_MEDIA(16)
}
@Serializable
data class GuildChannel(
val id: String,
val type: Int,
val name: String?
)
suspend fun getDiscordUser(accessToken: String): DiscordMeAPI? {
if(DiscordRatelimits.isLimited()) {
delay(DiscordRatelimits.getRateReset())
}
val response: HttpResponse = applicationHttpClient.get("https://discord.com/api/oauth2/@me") {
headers {
append(HttpHeaders.Authorization, "Bearer $accessToken")
}
}
val rateLimit = response.headers["X-RateLimit-Limit"]?.toIntOrNull()
val remaining = response.headers["X-RateLimit-Remaining"]?.toIntOrNull()
val resetAfter = response.headers["X-RateLimit-Reset-After"]?.toDoubleOrNull()?.toLong()
DiscordRatelimits.setRateLimit(rateLimit, remaining, resetAfter)
return response.body<DiscordMeAPI?>()
}
suspend fun getUserGuilds(accessToken: String): List<DiscordGuildListAPI> {
if(DiscordRatelimits.isLimited()) {
delay(DiscordRatelimits.getRateReset())
}
val response = applicationHttpClient.get("https://discord.com/api/users/@me/guilds") {
headers {
append(HttpHeaders.Authorization, "Bearer $accessToken")
}
}
val rateLimit = response.headers["X-RateLimit-Limit"]?.toIntOrNull()
val remaining = response.headers["X-RateLimit-Remaining"]?.toIntOrNull()
val resetAfter = response.headers["X-RateLimit-Reset-After"]?.toDoubleOrNull()?.toLong()
DiscordRatelimits.setRateLimit(rateLimit, remaining, resetAfter)
return response.body<List<DiscordGuildListAPI>>()
}
@Serializable
data class ChzzkMeApi(
val channelId: String,
val channelName: String,
val nickname: String,
)
@Serializable
data class ChzzkApi<T>(
val code: Int,
val message: String?,
val content: T?
)
suspend fun getChzzkUser(accessToken: String): ChzzkApi<ChzzkMeApi> {
val response = applicationHttpClient.get("https://openapi.chzzk.naver.com/open/v1/users/me") {
headers {
append(HttpHeaders.Authorization, "Bearer $accessToken")
}
}
return response.body<ChzzkApi<ChzzkMeApi>>()
}
fun generateSecureRandomState(): String {
return BigInteger(130, SecureRandom()).toString(32)
}

View File

@ -0,0 +1,41 @@
package space.mori.chzzk_bot.webserver
import io.ktor.server.sessions.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.models.Session
import space.mori.chzzk_bot.common.models.Sessions as SessionTable
class MariadbSessionStorage: SessionStorage {
override suspend fun invalidate(id: String) {
return transaction {
val session = Session.find(
SessionTable.key eq id
).firstOrNull()
session?.delete()
}
}
override suspend fun read(id: String): String {
return transaction {
val session = Session.find(SessionTable.key eq id).firstOrNull()
?: throw NoSuchElementException("Session $id not found")
session.value
}
}
override suspend fun write(id: String, value: String) {
return transaction {
val session = Session.find(SessionTable.key eq id).firstOrNull()
if (session == null) {
Session.new {
this.key = id
this.value = value
}
} else {
session.value = value
}
}
}
}

View File

@ -0,0 +1,17 @@
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
@OptIn(ExperimentalSerializationApi::class)
val applicationHttpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
explicitNulls = false
})
}
}

View File

@ -0,0 +1,168 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.common.events.CommandReloadEvent
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.common.services.CommandService
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.UserSession
fun Routing.apiCommandRoutes() {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
route("/commands") {
get("/{uid}") {
val uid = call.parameters["uid"]
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@get
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
val commands = CommandService.getCommands(user)
call.respond(HttpStatusCode.OK, commands.map {
CommandsResponseDTO(it.command, it.content, it.failContent)
})
}
put("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
val commandRequest = call.receive<CommandsRequestDTO>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@put
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@put
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@put
}
CommandService.saveCommand(user,
commandRequest.label,
commandRequest.content,
commandRequest.failContent ?: ""
)
CoroutineScope(Dispatchers.Default).launch {
for(i: Int in 0..3) {
dispatcher.post(CommandReloadEvent(user.token))
}
}
call.respond(HttpStatusCode.OK)
}
post("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
val commandRequest = call.receive<CommandsRequestDTO>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@post
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@post
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@post
}
try {
CommandService.updateCommand(
user,
commandRequest.label,
commandRequest.content,
commandRequest.failContent ?: ""
)
CoroutineScope(Dispatchers.Default).launch {
for(i: Int in 0..3) {
dispatcher.post(CommandReloadEvent(user.token))
}
}
call.respond(HttpStatusCode.OK)
} catch(e: Exception) {
call.respond(HttpStatusCode.BadRequest)
}
}
delete("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
val commandRequest = call.receive<CommandsRequestDTO>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@delete
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@delete
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@delete
}
try {
CommandService.removeCommand(user, commandRequest.label)
CoroutineScope(Dispatchers.Default).launch {
for(i: Int in 0..3) {
dispatcher.post(CommandReloadEvent(user.token))
}
}
call.respond(HttpStatusCode.OK)
} catch(e: Exception) {
call.respond(HttpStatusCode.BadRequest)
}
}
}
}
@Serializable
data class CommandsRequestDTO(
val label: String,
val content: String,
val failContent: String?
)
@Serializable
data class CommandsResponseDTO(
val label: String,
val content: String,
val failContent: String?
)

View File

@ -0,0 +1,120 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.UserSession
import space.mori.chzzk_bot.webserver.utils.DiscordGuildCache
fun Route.apiDiscordRoutes() {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
route("/discord") {
get("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@get
}
val user = UserService.getUser(uid)
if(user?.token == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
if (user.discord == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
call.respond(HttpStatusCode.OK, GuildSettings(
user.liveAlertGuild.toString(),
user.liveAlertChannel.toString(),
user.liveAlertMessage
))
return@get
}
post("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
val body: GuildSettings = call.receive()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@post
}
val user = UserService.getUser(uid)
if(user?.token == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@post
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@post
}
UserService.updateLiveAlert(user, body.guildId?.toLong() ?: 0L, body.channelId?.toLong() ?: 0L, body.message)
call.respond(HttpStatusCode.OK)
}
get("/guild/{gid}") {
val gid = call.parameters["gid"]
val session = call.sessions.get<UserSession>()
if(gid == null) {
call.respond(HttpStatusCode.BadRequest, "GID is required")
return@get
}
if(session == null) {
call.respond(HttpStatusCode.BadRequest, "Session is required")
return@get
}
val user = UserService.getUser(session.id)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
val guild = DiscordGuildCache.getCachedGuilds(gid)
if(guild == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
call.respond(HttpStatusCode.OK, guild)
return@get
}
get("/guilds") {
val session = call.sessions.get<UserSession>()
if(session == null) {
call.respond(HttpStatusCode.BadRequest, "Session is required")
return@get
}
call.respond(HttpStatusCode.OK, DiscordGuildCache.getCachedGuilds(session.discordGuildList))
return@get
}
}
}
@Serializable
data class GuildSettings(
val guildId: String?,
val channelId: String?,
val message: String? = null,
)

View File

@ -0,0 +1,149 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.java.KoinJavaComponent.inject
import space.mori.chzzk_bot.common.events.CoroutinesEventBus
import space.mori.chzzk_bot.common.services.SongConfigService
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.UserSession
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
import space.mori.chzzk_bot.common.events.ChzzkUserFindEvent
import space.mori.chzzk_bot.common.events.ChzzkUserReceiveEvent
@Serializable
data class GetUserDTO(
val uid: String,
val nickname: String,
val isStreamOn: Boolean,
val avatarUrl: String
)
@Serializable
data class GetSessionDTO(
val uid: String,
val nickname: String,
val isStreamOn: Boolean,
val avatarUrl: String,
val maxQueueSize: Int,
val maxUserSize: Int,
val isStreamerOnly: Boolean,
val isDisabled: Boolean
)
fun Routing.apiRoutes() {
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
suspend fun getChzzkUserWithId(uid: String): ChzzkUserReceiveEvent? {
val completableDeferred = CompletableDeferred<ChzzkUserReceiveEvent>()
dispatcher.subscribe(ChzzkUserReceiveEvent::class) { event ->
if (event.uid == uid) {
completableDeferred.complete(event)
}
}
val user = withTimeoutOrNull(5000) {
dispatcher.post(ChzzkUserFindEvent(uid))
completableDeferred.await()
}
return user
}
route("/") {
get {
call.respondText("Hello World!", status =
HttpStatusCode.OK)
}
}
route("/health") {
get {
call.respondText("OK", status= HttpStatusCode.OK)
}
}
route("/user/{uid}") {
get {
val uid = call.parameters["uid"]
if(uid == null) {
call.respondText("Require UID", status = HttpStatusCode.NotFound)
return@get
}
val user = getChzzkUserWithId(uid)
if (user?.find == false) {
call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get
} else {
call.respond(HttpStatusCode.OK, GetUserDTO(
user?.uid ?: "",
user?.nickname ?: "",
user?.isStreamOn ?: false,
user?.avatarUrl ?: ""
))
}
}
}
route("/user") {
get {
val session = call.sessions.get<UserSession>()
if(session == null) {
call.respondText("No session found", status = HttpStatusCode.Unauthorized)
return@get
}
var user = UserService.getUser(session.id)
if(user == null) {
user = UserService.saveUser("임시닉네임", session.id)
}
val songConfig = SongConfigService.getConfig(user)
val status = getChzzkUserWithId(user.token)
val returnUsers = mutableListOf<GetSessionDTO>()
if(status == null) {
call.respondText("No user found", status = HttpStatusCode.NotFound)
return@get
}
if (user.username == "임시닉네임") {
status.let { stats -> UserService.updateUser(user, stats.uid ?: "", stats.nickname ?: "") }
}
returnUsers.add(GetSessionDTO(
status.uid ?: user.token,
status.nickname ?: user.username,
status.isStreamOn == true,
status.avatarUrl ?: "",
songConfig.queueLimit,
songConfig.personalLimit,
songConfig.streamerOnly,
songConfig.disabled
))
val subordinates = transaction {
user.subordinates.toList()
}
returnUsers.addAll(subordinates.map {
val subStatus = getChzzkUserWithId(it.token)
return@map if (subStatus == null) {
null
} else {
GetSessionDTO(
subStatus.uid ?: "",
subStatus.nickname ?: "",
subStatus.isStreamOn == true,
subStatus.avatarUrl ?: "",
0,
0,
false,
false
)
}
}.filterNotNull())
call.respond(HttpStatusCode.OK, returnUsers)
}
}
}

View File

@ -0,0 +1,69 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import space.mori.chzzk_bot.common.models.SongList
import space.mori.chzzk_bot.common.services.SongListService
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.YoutubeVideo
import space.mori.chzzk_bot.webserver.utils.CurrentSong
@Serializable
data class SongsDTO(
val url: String,
val name: String,
val author: String,
val time: Int,
val reqName: String
)
@Serializable
data class SongsResponseDTO(
val current: SongsDTO? = null,
val next: List<SongsDTO> = emptyList()
)
fun SongList.toDTO(): SongsDTO = SongsDTO(
this.url,
this.name,
this.author,
this.time,
this.reqName
)
fun YoutubeVideo.toDTO(): SongsDTO = SongsDTO(
this.url,
this.name,
this.author,
this.length,
""
)
fun Routing.apiSongRoutes() {
route("/songs/{uid}") {
get {
val uid = call.parameters["uid"]
val user = uid?.let { it1 -> UserService.getUser(it1) }
if (user == null) {
call.respondText("No user found", status = HttpStatusCode.NotFound)
return@get
}
val songs = SongListService.getSong(user)
call.respond(HttpStatusCode.OK,
SongsResponseDTO(
CurrentSong.getSong(user)?.toDTO(),
songs.map { it.toDTO() }
)
)
}
}
route("/songs") {
get {
call.respondText("Require UID", status= HttpStatusCode.BadRequest)
}
}
}

View File

@ -0,0 +1,79 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.transactions.transaction
import space.mori.chzzk_bot.common.events.TimerType
import space.mori.chzzk_bot.common.services.TimerConfigService
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.UserSession
fun Routing.apiTimerRoutes() {
route("/timerapi") {
get("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@get
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@get
}
val timerConfig = TimerConfigService.getConfig(user)
call.respond(HttpStatusCode.OK, TimerResponseDTO(timerConfig?.option ?: 0))
}
put("/{uid}") {
val uid = call.parameters["uid"]
val session = call.sessions.get<UserSession>()
val request = call.receive<TimerRequestDTO>()
if(uid == null) {
call.respond(HttpStatusCode.BadRequest, "UID is required")
return@put
}
val user = UserService.getUser(uid)
if(user == null) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@put
}
val managers = transaction {
user.managers.toList()
}
if(!managers.any { it.token == session?.id } && user.token != session?.id) {
call.respond(HttpStatusCode.BadRequest, "User does not exist")
return@put
}
TimerConfigService.saveOrUpdateConfig(user, TimerType.entries[request.option])
call.respond(HttpStatusCode.OK)
}
}
}
@Serializable
data class TimerRequestDTO(
val option: Int
)
@Serializable
data class TimerResponseDTO(
val option: Int
)

View File

@ -0,0 +1,357 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.client.plugins.websocket.WebSocketException
import io.ktor.server.application.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.models.SongList
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.SongListService
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.YoutubeVideo
import space.mori.chzzk_bot.common.utils.getYoutubeVideo
import space.mori.chzzk_bot.webserver.UserSession
import space.mori.chzzk_bot.webserver.utils.CurrentSong
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
fun Routing.wsSongListRoutes() {
val logger = LoggerFactory.getLogger("WSSongListRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val songListScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Manage all active sessions
val sessionHandlers = ConcurrentHashMap<String, SessionHandler>()
// Handle application shutdown
environment.monitor.subscribe(ApplicationStopped) {
sessionHandlers.values.forEach {
songListScope.launch {
it.close(CloseReason(CloseReason.Codes.NORMAL, "Server shutting down"))
}
}
}
// WebSocket endpoint
webSocket("/songlist") {
val session = call.sessions.get<UserSession>()
val user: User? = session?.id?.let { UserService.getUser(it) }
if (user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID"))
return@webSocket
}
val uid = user.token
// Ensure only one session per user
sessionHandlers[uid]?.close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Another session is already active."))
val handler = SessionHandler(uid, this, dispatcher, logger)
sessionHandlers[uid] = handler
// Initialize session
handler.initialize()
// Listen for incoming frames
try {
for (frame in incoming) {
when (frame) {
is Frame.Text -> handler.handleTextFrame(frame.readText())
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> Unit
}
}
} catch (e: ClosedReceiveChannelException) {
logger.info("Session closed: ${e.message}")
} catch (e: IOException) {
logger.error("IO error: ${e.message}")
} catch (e: Exception) {
logger.error("Unexpected error: ${e.message}")
} finally {
sessionHandlers.remove(uid)
handler.close(CloseReason(CloseReason.Codes.NORMAL, "Session ended"))
}
}
// Subscribe to SongEvents
dispatcher.subscribe(SongEvent::class) { event ->
val handler = sessionHandlers[event.uid]
songListScope.launch {
handler?.sendSongResponse(event)
}
}
// Subscribe to TimerEvents
dispatcher.subscribe(TimerEvent::class) { event ->
if (event.type == TimerType.STREAM_OFF) {
val handler = sessionHandlers[event.uid]
songListScope.launch {
handler?.sendTimerOff()
}
}
}
}
class SessionHandler(
private val uid: String,
private val session: WebSocketServerSession,
private val dispatcher: CoroutinesEventBus,
private val logger: Logger
) {
private val ackMap = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()
private val sessionMutex = Mutex()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
suspend fun initialize() {
// Send initial status if needed,
// For example, send STREAM_OFF if applicable
// This can be extended based on your requirements
}
suspend fun handleTextFrame(text: String) {
if (text.trim() == "ping") {
session.send("pong")
return
}
val data = try {
Json.decodeFromString<SongRequest>(text)
} catch (e: Exception) {
logger.warn("Failed to decode SongRequest: ${e.message}")
return
}
when (data.type) {
SongType.ACK.value -> handleAck(data.uid)
else -> handleSongRequest(data)
}
}
private fun handleAck(requestUid: String) {
ackMap[requestUid]?.complete(true)
ackMap.remove(requestUid)
}
private fun handleSongRequest(data: SongRequest) {
scope.launch {
SongRequestProcessor.process(data, uid, dispatcher, this@SessionHandler, logger)
}
}
suspend fun sendSongResponse(event: SongEvent) {
val response = SongResponse(
type = event.type.value,
uid = event.uid,
reqUid = event.reqUid,
current = event.current?.toSerializable(),
next = event.next?.toSerializable(),
delUrl = event.delUrl
)
sendWithRetry(response)
}
suspend fun sendTimerOff() {
val response = SongResponse(
type = TimerType.STREAM_OFF.value,
uid = uid,
reqUid = null,
current = null,
next = null,
delUrl = null
)
sendWithRetry(response)
}
private suspend fun sendWithRetry(res: SongResponse, maxRetries: Int = 5, delayMillis: Long = 3000L) {
var attempt = 0
while (attempt < maxRetries) {
try {
session.sendSerialized(res)
val ackDeferred = CompletableDeferred<Boolean>()
ackMap[res.uid] = ackDeferred
val ackReceived = withTimeoutOrNull(5000L) { ackDeferred.await() } ?: false
if (ackReceived) {
logger.debug("ACK received for message to $uid on attempt $attempt.")
return
} else {
logger.warn("ACK not received for message to $uid on attempt $attempt.")
}
} catch (e: IOException) {
logger.warn("Failed to send message to $uid on attempt $attempt: ${e.message}")
if (e is WebSocketException) {
close(CloseReason(CloseReason.Codes.PROTOCOL_ERROR, "WebSocket error"))
return
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.warn("Unexpected error while sending message to $uid on attempt $attempt: ${e.message}")
}
attempt++
delay(delayMillis)
}
logger.error("Failed to send message to $uid after $maxRetries attempts.")
}
suspend fun close(reason: CloseReason) {
try {
session.close(reason)
} catch (e: Exception) {
logger.warn("Error closing session: ${e.message}")
}
}
}
object SongRequestProcessor {
private val songMutex = Mutex()
suspend fun process(
data: SongRequest,
uid: String,
dispatcher: CoroutinesEventBus,
handler: SessionHandler,
logger: Logger
) {
val user = UserService.getUser(uid) ?: return
when (data.type) {
SongType.ADD.value -> handleAdd(data, user, dispatcher, handler, logger)
SongType.REMOVE.value -> handleRemove(data, user, dispatcher, logger)
SongType.NEXT.value -> handleNext(user, dispatcher, logger)
else -> {
// Handle other types if necessary
}
}
}
private suspend fun handleAdd(
data: SongRequest,
user: User,
dispatcher: CoroutinesEventBus,
handler: SessionHandler,
logger: Logger
) {
val url = data.url ?: return
val youtubeVideo = getYoutubeVideo(url) ?: run {
logger.warn("Failed to fetch YouTube video for URL: $url")
return
}
songMutex.withLock {
SongListService.saveSong(
user,
user.token,
url,
youtubeVideo.name,
youtubeVideo.author,
youtubeVideo.length,
user.username
)
}
dispatcher.post(
SongEvent(
uid = user.token,
type = SongType.ADD,
reqUid = user.token,
current = CurrentSong.getSong(user),
next = youtubeVideo
)
)
}
private suspend fun handleRemove(
data: SongRequest,
user: User,
dispatcher: CoroutinesEventBus,
logger: Logger
) {
val url = data.url ?: return
songMutex.withLock {
val songs = SongListService.getSong(user)
val exactSong = songs.firstOrNull { it.url == url }
if (exactSong != null) {
SongListService.deleteSong(user, exactSong.uid, exactSong.name)
}
}
dispatcher.post(
SongEvent(
uid = user.token,
type = SongType.REMOVE,
delUrl = url,
reqUid = null,
current = null,
next = null,
)
)
}
private suspend fun handleNext(
user: User,
dispatcher: CoroutinesEventBus,
logger: Logger
) {
var song: SongList? = null
var youtubeVideo: YoutubeVideo? = null
songMutex.withLock {
val songList = SongListService.getSong(user)
if (songList.isNotEmpty()) {
song = songList[0]
SongListService.deleteSong(user, song.uid, song.name)
}
}
song?.let {
youtubeVideo = YoutubeVideo(
it.url,
it.name,
it.author,
it.time
)
}
dispatcher.post(
SongEvent(
uid = user.token,
type = SongType.NEXT,
current = null,
next = youtubeVideo,
reqUid = null,
delUrl = null
)
)
CurrentSong.setSong(user, youtubeVideo)
}
}
@Serializable
data class SongRequest(
val type: Int,
val uid: String,
val url: String? = null,
val maxQueue: Int? = null,
val maxUserLimit: Int? = null,
val isStreamerOnly: Boolean? = null,
val remove: Int? = null,
val isDisabled: Boolean? = null
)

View File

@ -0,0 +1,213 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.common.utils.YoutubeVideo
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
val songScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun Routing.wsSongRoutes() {
environment.monitor.subscribe(ApplicationStopped) {
songScope.cancel()
}
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, SongType>()
val logger = LoggerFactory.getLogger("WSSongRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val ackMap = ConcurrentHashMap<String, ConcurrentHashMap<WebSocketServerSession, CompletableDeferred<Boolean>>>()
fun addSession(uid: String, session: WebSocketServerSession) {
sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session)
}
fun removeSession(uid: String, session: WebSocketServerSession) {
sessions[uid]?.remove(session)
if (sessions[uid]?.isEmpty() == true) {
sessions.remove(uid)
}
}
suspend fun sendWithRetry(
session: WebSocketServerSession,
message: SongResponse,
maxRetries: Int = 3,
delayMillis: Long = 2000L
): Boolean {
var attempt = 0
while (attempt < maxRetries) {
try {
session.sendSerialized(message)
val ackDeferred = CompletableDeferred<Boolean>()
ackMap.computeIfAbsent(message.uid) { ConcurrentHashMap() }[session] = ackDeferred
val ackReceived = withTimeoutOrNull(delayMillis) { ackDeferred.await() } ?: false
if (ackReceived) {
ackMap[message.uid]?.remove(session)
return true
} else {
attempt++
logger.warn("ACK not received for message to ${message.uid} on attempt $attempt.")
}
} catch (e: Exception) {
attempt++
logger.info("Failed to send message on attempt $attempt. Retrying in $delayMillis ms.")
e.printStackTrace()
delay(delayMillis)
}
}
return false
}
fun broadcastMessage(userId: String, message: SongResponse) {
val userSessions = sessions[userId]
userSessions?.forEach { session ->
songScope.launch {
val success = sendWithRetry(session, message)
if (!success) {
logger.info("Removing session for user $userId due to repeated failures.")
removeSession(userId, session)
}
}
}
}
webSocket("/song/{uid}") {
logger.info("WebSocket connection attempt received")
val uid = call.parameters["uid"]
val user = uid?.let { UserService.getUser(it) }
if (uid == null || user == null) {
logger.warn("Invalid UID: $uid")
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
try {
addSession(uid, this)
logger.info("WebSocket connection established for user: $uid")
// Start heartbeat
val heartbeatJob = songScope.launch {
while (true) {
try {
send(Frame.Ping(ByteArray(0)))
delay(30000) // 30 seconds
} catch (e: Exception) {
logger.error("Heartbeat failed for user $uid", e)
break
}
}
}
if (status[uid] == SongType.STREAM_OFF) {
songScope.launch {
sendSerialized(
SongResponse(
SongType.STREAM_OFF.value,
uid,
null,
null,
null,
)
)
}
}
try {
for (frame in incoming) {
when (frame) {
is Frame.Text -> {
val text = frame.readText().trim()
if (text == "ping") {
send("pong")
} else {
val data = Json.decodeFromString<SongRequest>(text)
if (data.type == SongType.ACK.value) {
ackMap[data.uid]?.get(this)?.complete(true)
ackMap[data.uid]?.remove(this)
}
}
}
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {}
}
}
} catch (e: ClosedReceiveChannelException) {
logger.error("WebSocket connection closed for user $uid: ${e.message}")
} catch (e: Exception) {
logger.error("Unexpected error in WebSocket for user $uid", e)
} finally {
logger.info("Cleaning up WebSocket connection for user $uid")
removeSession(uid, this)
ackMap[uid]?.remove(this)
heartbeatJob.cancel()
}
} catch(e: Exception) {
logger.error("Unexpected error in WebSocket for user $uid", e)
}
}
dispatcher.subscribe(SongEvent::class) {
logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.current?.name)
songScope.launch {
broadcastMessage(
it.uid, SongResponse(
it.type.value,
it.uid,
it.reqUid,
it.current?.toSerializable(),
it.next?.toSerializable(),
it.delUrl
)
)
}
}
dispatcher.subscribe(TimerEvent::class) {
if (it.type == TimerType.STREAM_OFF) {
songScope.launch {
broadcastMessage(
it.uid, SongResponse(
it.type.value,
it.uid,
null,
null,
null,
)
)
}
}
}
}
@Serializable
data class SerializableYoutubeVideo(
val url: String,
val name: String,
val author: String,
val length: Int
)
fun YoutubeVideo.toSerializable() = SerializableYoutubeVideo(url, name, author, length)
@Serializable
data class SongResponse(
val type: Int,
val uid: String,
val reqUid: String?,
val current: SerializableYoutubeVideo? = null,
val next: SerializableYoutubeVideo? = null,
val delUrl: String? = null
)

View File

@ -0,0 +1,175 @@
package space.mori.chzzk_bot.webserver.routes
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.koin.java.KoinJavaComponent.inject
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.common.events.*
import space.mori.chzzk_bot.common.services.TimerConfigService
import space.mori.chzzk_bot.common.services.UserService
import space.mori.chzzk_bot.webserver.utils.CurrentTimer
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
val timerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun Routing.wsTimerRoutes() {
environment.monitor.subscribe(ApplicationStopped) {
timerScope.cancel()
}
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val logger = LoggerFactory.getLogger("WSTimerRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val ackMap = ConcurrentHashMap<String, ConcurrentHashMap<WebSocketServerSession, CompletableDeferred<Boolean>>>()
fun addSession(uid: String, session: WebSocketServerSession) {
sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session)
}
fun removeSession(uid: String, session: WebSocketServerSession) {
sessions[uid]?.remove(session)
if(sessions[uid]?.isEmpty() == true) {
sessions.remove(uid)
}
}
suspend fun sendWithRetry(
session: WebSocketServerSession,
message: TimerResponse,
maxRetries: Int = 3,
delayMillis: Long = 2000L
): Boolean {
var attempt = 0
while (attempt < maxRetries) {
try {
session.sendSerialized(message)
val ackDeferred = CompletableDeferred<Boolean>()
ackMap.computeIfAbsent(message.uid) { ConcurrentHashMap() }[session] = ackDeferred
val ackReceived = withTimeoutOrNull(delayMillis) { ackDeferred.await() } ?: false
if (ackReceived) {
ackMap[message.uid]?.remove(session)
return true
} else {
attempt++
logger.warn("ACK not received for message to ${message.uid} on attempt $attempt.")
}
} catch (e: Exception) {
attempt++
logger.info("Failed to send message on attempt $attempt. Retrying in $delayMillis ms.")
e.printStackTrace()
delay(delayMillis)
}
}
return false
}
fun broadcastMessage(uid: String, message: TimerResponse) {
val userSessions = sessions[uid]
userSessions?.forEach { session ->
timerScope.launch {
val success = sendWithRetry(session, message.copy(uid = uid))
if (!success) {
logger.info("Removing session for user $uid due to repeated failures.")
removeSession(uid, session)
}
}
}
}
webSocket("/timer/{uid}") {
val uid = call.parameters["uid"]
val user = uid?.let { UserService.getUser(it) }
if (uid == null || user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
addSession(uid, this)
val timer = CurrentTimer.getTimer(user)
if (timer?.type == TimerType.STREAM_OFF) {
timerScope.launch {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null, uid))
}
} else {
timerScope.launch {
if(timer?.type == TimerType.STREAM_OFF) {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null, uid))
} else {
if (timer == null) {
sendSerialized(
TimerResponse(
TimerConfigService.getConfig(user)?.option ?: TimerType.REMOVE.value,
null,
uid
)
)
} else {
sendSerialized(
TimerResponse(
timer.type.value,
timer.time,
uid
)
)
}
}
}
}
try {
for (frame in incoming) {
when(frame) {
is Frame.Text -> {
val text = frame.readText().trim()
if(text == "ping") {
send("pong")
} else {
val data = Json.decodeFromString<TimerRequest>(text)
if (data.type == TimerType.ACK.value) {
ackMap[data.uid]?.get(this)?.complete(true)
ackMap[data.uid]?.remove(this)
}
}
}
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {}
}
}
} catch(e: ClosedReceiveChannelException) {
logger.error("Error in WebSocket: ${e.message}")
} finally {
removeSession(uid, this)
ackMap[uid]?.remove(this)
}
}
dispatcher.subscribe(TimerEvent::class) {
logger.debug("TimerEvent: {} / {}", it.uid, it.type)
val user = UserService.getUser(it.uid)
CurrentTimer.setTimer(user!!, it)
timerScope.launch {
broadcastMessage(it.uid, TimerResponse(it.type.value, it.time ?: "", it.uid))
}
}
}
@Serializable
data class TimerResponse(
val type: Int,
val time: String?,
val uid: String
)
@Serializable
data class TimerRequest(
val type: Int,
val uid: String
)

View File

@ -0,0 +1,19 @@
package space.mori.chzzk_bot.webserver.utils
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.utils.YoutubeVideo
import java.util.concurrent.ConcurrentHashMap
object CurrentSong {
private val currentSong = ConcurrentHashMap<String, YoutubeVideo>()
fun setSong(user: User, song: YoutubeVideo?) {
if(song == null) {
currentSong.remove(user.token ?: "")
} else {
currentSong[user.token ?: ""] = song
}
}
fun getSong(user: User) = currentSong[user.token ?: ""]
}

View File

@ -0,0 +1,19 @@
package space.mori.chzzk_bot.webserver.utils
import space.mori.chzzk_bot.common.events.TimerEvent
import space.mori.chzzk_bot.common.models.User
import java.util.concurrent.ConcurrentHashMap
object CurrentTimer {
private val currentTimer = ConcurrentHashMap<String, TimerEvent>()
fun setTimer(user: User, timer: TimerEvent?) {
if(timer == null) {
currentTimer.remove(user.token ?: "")
} else {
currentTimer[user.token ?: ""] = timer
}
}
fun getTimer(user: User) = currentTimer[user.token ?: ""]
}

View File

@ -0,0 +1,188 @@
package space.mori.chzzk_bot.webserver.utils
import applicationHttpClient
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.webserver.*
import java.time.Instant
import java.util.concurrent.ConcurrentHashMap
object DiscordGuildCache {
private val cache = ConcurrentHashMap<String, CachedGuilds>()
private const val EXP_SECONDS = 600L
private val mutex = Mutex()
private val logger = LoggerFactory.getLogger(this::class.java)
suspend fun getCachedGuilds(guildId: String): Guild? {
val now = Instant.now()
var guild = cache[guildId]
if(guild == null || guild.timestamp.plusSeconds(EXP_SECONDS).isBefore(now) || !guild.isBotAvailable) {
mutex.withLock {
if(guild == null || guild!!.timestamp.plusSeconds(EXP_SECONDS).isBefore(now) || !guild!!.isBotAvailable) {
fetchAllGuilds()
guild = cache[guildId]
}
}
}
try {
if(guild == null) return null
if (guild!!.guild.roles.isEmpty()) {
val roles = fetchGuildRoles(guildId)
guild!!.guild.roles.addAll(roles)
}
if (guild!!.guild.channel.isEmpty()) {
val channels = fetchGuildChannels(guildId)
guild!!.guild.channel.addAll(channels)
}
} catch(e: Exception) {
logger.info("guild fetch is failed. ${e.stackTraceToString()}")
return null
}
return cache[guildId]?.guild
}
suspend fun getCachedGuilds(guildId: List<String>): List<Guild> {
return guildId.mapNotNull { getCachedGuilds(it) }
}
private suspend fun fetchGuilds(beforeGuildId: String? = null): List<DiscordGuildListAPI> {
if(DiscordRatelimits.isLimited()) {
delay(DiscordRatelimits.getRateReset())
}
val result = applicationHttpClient.get("https://discord.com/api/users/@me/guilds") {
headers {
append(HttpHeaders.Authorization, "Bot ${dotenv["DISCORD_TOKEN"]}")
}
parameter("limit", 200)
if (beforeGuildId != null) {
parameter("before", beforeGuildId)
}
}
val rateLimit = result.headers["X-RateLimit-Limit"]?.toIntOrNull()
val remaining = result.headers["X-RateLimit-Remaining"]?.toIntOrNull()
val resetAfter = result.headers["X-RateLimit-Reset-After"]?.toDoubleOrNull()?.toLong()?.plus(1L)
DiscordRatelimits.setRateLimit(rateLimit, remaining, resetAfter)
return result.body<List<DiscordGuildListAPI>>()
}
private suspend fun fetchGuildRoles(guildId: String): MutableList<GuildRole> {
if(DiscordRatelimits.isLimited()) {
delay(DiscordRatelimits.getRateReset())
}
try {
val result = applicationHttpClient.get("https://discord.com/api/guilds/${guildId}/roles") {
headers {
append(HttpHeaders.Authorization, "Bot ${dotenv["DISCORD_TOKEN"]}")
}
}
val rateLimit = result.headers["X-RateLimit-Limit"]?.toIntOrNull()
val remaining = result.headers["X-RateLimit-Remaining"]?.toIntOrNull()
val resetAfter = result.headers["X-RateLimit-Reset-After"]?.toDoubleOrNull()?.toLong()?.plus(1L)
DiscordRatelimits.setRateLimit(rateLimit, remaining, resetAfter)
if (result.status != HttpStatusCode.OK) {
logger.error("Failed to fetch data from Discord API. Status: ${result.status} ${result.bodyAsText()}")
return mutableListOf()
}
val parsed = result.body<MutableList<GuildRole>>()
return parsed
} catch(e: Exception) {
logger.info("fetchGuildRoles error: ${e.stackTraceToString()}")
return mutableListOf()
}
}
private suspend fun fetchGuildChannels(guildId: String): MutableList<GuildChannel> {
if(DiscordRatelimits.isLimited()) {
delay(DiscordRatelimits.getRateReset())
}
try {
val result = applicationHttpClient.get("https://discord.com/api/guilds/${guildId}/channels") {
headers {
append(HttpHeaders.Authorization, "Bot ${dotenv["DISCORD_TOKEN"]}")
}
}
val rateLimit = result.headers["X-RateLimit-Limit"]?.toIntOrNull()
val remaining = result.headers["X-RateLimit-Remaining"]?.toIntOrNull()
val resetAfter = result.headers["X-RateLimit-Reset-After"]?.toDoubleOrNull()?.toLong()?.plus(1L)
DiscordRatelimits.setRateLimit(rateLimit, remaining, resetAfter)
if (result.status != HttpStatusCode.OK) {
logger.error("Failed to fetch data from Discord API. Status: ${result.status} ${result.bodyAsText()}")
return mutableListOf()
}
val parsed = result.body<List<GuildChannel>>().filter { it.type == ChannelType.GUILD_TEXT.value }.toMutableList()
return parsed
} catch(e: Exception) {
logger.info("fetchGuildRoles error: ${e.stackTraceToString()}")
return mutableListOf()
}
}
private suspend fun fetchAllGuilds() {
var lastGuildId: String? = null
while (true) {
try {
val guilds = fetchGuilds(lastGuildId)
if (guilds.isEmpty()) {
break
}
guilds.forEach {
cache[it.id] = CachedGuilds(
Guild(it.id, it.name, it.icon, it.banner, it.roles?.toMutableList() ?: mutableListOf(), mutableListOf()),
Instant.now().plusSeconds(EXP_SECONDS),
true
)
}
lastGuildId = guilds.last().id
if(guilds.size <= 200) break
} catch(e: Exception) {
logger.info("Exception in discord caches. ${e.stackTraceToString()}")
return
}
}
}
fun addGuild(guilds: Map<String, Guild>) {
cache.putAll(guilds.map {
it.key to CachedGuilds(it.value, Instant.now().plusSeconds(EXP_SECONDS))
})
}
}
data class CachedGuilds(
val guild: Guild,
val timestamp: Instant = Instant.now(),
val isBotAvailable: Boolean = false,
)
@Serializable
data class Guild(
val id: String,
val name: String,
val icon: String?,
val banner: String?,
var roles: MutableList<GuildRole>,
var channel: MutableList<GuildChannel>
)

View File

@ -0,0 +1,36 @@
package space.mori.chzzk_bot.webserver.utils
import java.time.Duration
import java.time.Instant
object DiscordRatelimits {
private var rateLimit = RateLimit(0, 5, Instant.now())
fun isLimited(): Boolean {
return rateLimit.remainin == 0
}
fun getRateReset(): Long {
val now = Instant.now()
val resetInstant = rateLimit.resetAfter
return if (resetInstant.isAfter(now)) {
Duration.between(now, resetInstant).toMillis()
} else {
0L // 이미 Rate Limit이 해제된 경우, 대기 시간은 0
}
}
private fun setRateLimit(rateLimit: RateLimit) {
this.rateLimit = rateLimit
}
fun setRateLimit(limit: Int?, remaining: Int?, resetAfter: Long?) {
return setRateLimit(RateLimit(limit ?: 0, remaining ?: 0, Instant.now().plusSeconds(resetAfter ?: 0L)))
}
}
data class RateLimit(
val limit: Int,
val remainin: Int,
val resetAfter: Instant,
)

View File

@ -0,0 +1,182 @@
openapi: "3.1.0"
info:
title: "chzzk_bot API"
description: "chzzk_bot API"
version: "1.0.0"
servers:
- url: "http://localhost:8080"
paths:
/:
get:
description: ""
responses:
"200":
description: "OK"
content:
text/plain:
schema:
type: "string"
examples:
Example#1:
value: "Hello World!"
/health:
get:
description: ""
responses:
"200":
description: "OK"
content:
text/plain:
schema:
type: "string"
examples:
Example#1:
value: "OK"
/song/{uid}:
get:
description: ""
parameters:
- name: "uid"
in: "path"
required: true
schema:
type: "string"
- name: "Connection"
in: "header"
required: true
description: "Websocket Connection parameter"
schema:
type: "string"
- name: "Upgrade"
in: "header"
required: true
description: "Websocket Upgrade parameter"
schema:
type: "string"
- name: "Sec-WebSocket-Key"
in: "header"
required: true
description: "Websocket Sec-WebSocket-Key parameter"
schema:
type: "string"
responses:
"101":
description: "Switching Protocols"
headers:
Connection:
required: true
schema:
type: "string"
Upgrade:
required: true
schema:
type: "string"
Sec-WebSocket-Accept:
required: true
schema:
type: "string"
/songs:
get:
description: ""
responses:
"400":
description: "Bad Request"
content:
text/plain:
schema:
type: "string"
examples:
Example#1:
value: "Require UID"
/songs/{uid}:
get:
description: ""
parameters:
- name: "uid"
in: "path"
required: true
schema:
type: "string"
responses:
"404":
description: "Not Found"
content:
text/plain:
schema:
type: "string"
examples:
Example#1:
value: "No user found"
"200":
description: "OK"
content:
'*/*':
schema:
type: "array"
items:
$ref: "#/components/schemas/SongList"
/timer/{uid}:
get:
description: ""
parameters:
- name: "uid"
in: "path"
required: true
schema:
type: "string"
- name: "Connection"
in: "header"
required: true
description: "Websocket Connection parameter"
schema:
type: "string"
- name: "Upgrade"
in: "header"
required: true
description: "Websocket Upgrade parameter"
schema:
type: "string"
- name: "Sec-WebSocket-Key"
in: "header"
required: true
description: "Websocket Sec-WebSocket-Key parameter"
schema:
type: "string"
responses:
"101":
description: "Switching Protocols"
headers:
Connection:
required: true
schema:
type: "string"
Upgrade:
required: true
schema:
type: "string"
Sec-WebSocket-Accept:
required: true
schema:
type: "string"
components:
schemas:
Object:
type: "object"
properties: {}
ResultRow:
type: "object"
properties:
fieldIndex:
type: "object"
required:
- "fieldIndex"
SongList:
type: "object"
properties:
writeValues:
$ref: "#/components/schemas/Object"
_readValues:
$ref: "#/components/schemas/ResultRow"
required:
- "id"
- "writeValues"