294 Commits

Author SHA1 Message Date
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 83b5eaf345, reversing
changes made to a99f3b342a.
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
58 changed files with 2447 additions and 1172 deletions

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>

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>

4
.idea/sqldialects.xml generated
View File

@@ -3,4 +3,8 @@
<component name="SqlDialectMappings">
<file url="PROJECT" dialect="MariaDB" />
</component>
<component name="SqlResolveMappings">
<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,7 +18,7 @@
- [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
@@ -79,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,5 +1,5 @@
plugins {
val kotlinVersion = "2.0.0"
val kotlinVersion = "2.0.21"
id("java")
id("application")
@@ -28,21 +28,21 @@ repositories {
dependencies {
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.6")
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/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-RC1")
implementation("io.insert-koin:koin-core:4.0.0")
kotlin("stdlib")

View File

@@ -11,32 +11,32 @@ repositories {
dependencies {
// https://mvnrepository.com/artifact/net.dv8tion/JDA
api("net.dv8tion:JDA:5.0.1") {
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.0.9")
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.6")
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/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/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-RC1")
implementation("io.insert-koin:koin-core:4.0.0")
testImplementation(kotlin("test"))

View File

@@ -1,47 +1,84 @@
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.chzzk
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.chat.ChatEventListener
import xyz.r2turntrue.chzzk4j.chat.ChatMessage
import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
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 = null))
handlers.add(UserHandler(chzzkChannel, logger, user, streamStartTime = LocalDateTime.now()))
}
fun enable() {
botUid = chzzk.loggedUser.userId
botUid = Connector.client.fetchLoggedUser().userId
UserService.getAllUsers().map {
chzzk.getChannel(it.token)?.let { token -> addUser(token, it) }
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 = getStreamInfo(handler.listener.channelId)
if (streamInfo.content?.status == "OPEN") handler.isActive(true, streamInfo)
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)
}
}
}
}
@@ -69,27 +106,90 @@ object ChzzkHandler {
fun runStreamInfo() {
running = true
val thread = Thread({
while(running) {
val threadRunner1 = Runnable {
logger.info("Thread 1 started!")
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()}")
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("Exception: ${it.channel.channelName} / ${e.stackTraceToString()}")
logger.info("Thread 1 Exception: ${it.channel.channelName} / ${e.stackTraceToString()}")
} finally {
Thread.sleep(5000)
}
}
Thread.sleep(60000)
}
}, "Chzzk-StreamInfo")
}
thread.start()
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() {
@@ -97,13 +197,18 @@ object ChzzkHandler {
}
}
@OptIn(DelicateCoroutinesApi::class)
class UserHandler(
val channel: ChzzkChannel,
val logger: Logger,
private var user: User,
var streamStartTime: LocalDateTime?,
) {
private lateinit var messageHandler: MessageHandler
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
@@ -111,33 +216,55 @@ class UserHandler(
LiveStatusService.updateOrCreate(user, value)
}
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(this@UserHandler)
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 = Connector.client.refreshAccessToken(user.refreshToken!!)
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)
}
override fun onError(ex: Exception) {
logger.info("ChzzkChat error. ${channel.channelName} - ${channel.channelId}")
logger.debug(ex.stackTraceToString())
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
)
)
}
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()
} catch(e: Exception) {
logger.error("Exception(${user.username}): ${e.stackTraceToString()}")
throw RuntimeException("Exception: ${e.stackTraceToString()}")
}
}
internal fun disable() {
listener.closeAsync()
listener.disconnectAsync().join()
_isActive = false
}
internal fun reloadCommand() {
@@ -151,17 +278,20 @@ class UserHandler(
internal val isActive: Boolean
get() = _isActive
internal fun isActive(value: Boolean, status: IData<IStreamInfo?>) {
internal fun isActive(value: Boolean, status: ChzzkLiveDetail) {
if(value) {
logger.info("${user.username} is live.")
logger.info("ChzzkChat connecting... ${channel.channelName} - ${channel.channelId}")
listener.connectBlocking()
streamStartTime = status.content?.openDate?.let { convertChzzkDateToLocalDateTime(it) }
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(
@@ -181,7 +311,8 @@ class UserHandler(
}
delay(5000L)
try {
listener.sendChat("${user.username} 님! 오늘도 열심히 방송하세요!")
if(!user.isDisableStartupMsg)
sendChat("${user.username} 님! 오늘도 열심히 방송하세요!")
Discord.sendDiscord(user, status)
} catch(e: Exception) {
logger.info("Stream on logic has some error: ${e.stackTraceToString()}")
@@ -191,7 +322,8 @@ class UserHandler(
} else {
logger.info("${user.username} is offline.")
streamStartTime = null
listener.closeAsync()
listener.disconnectAsync().join()
_isActive = false
CoroutineScope(Dispatchers.Default).launch {
val events = listOf(
@@ -207,13 +339,29 @@ class UserHandler(
null,
null,
null,
null,
null
)
)
events.forEach { dispatcher.post(it) }
}
}
_isActive = value
}
}
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

@@ -1,24 +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 xyz.r2turntrue.chzzk4j.Chzzk
import xyz.r2turntrue.chzzk4j.ChzzkBuilder
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 chzzk: Chzzk = ChzzkBuilder()
.withAuthorization(dotenv["NID_AUT"], dotenv["NID_SES"])
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? = chzzk.getChannel(channelId)
fun getChannel(channelId: String): ChzzkChannel? = client.fetchChannel(channelId)
fun getLive(channelId: String): ChzzkLiveDetail? = client.fetchLiveDetail(channelId)
init {
logger.info("chzzk logged: ${chzzk.isLoggedIn} / ${chzzk.loggedUser?.nickname ?: "----"}")
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

@@ -13,16 +13,16 @@ 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
import java.util.UUID
class MessageHandler(
private val handler: UserHandler
) {
private val commands = mutableMapOf<String, (msg: ChatMessage, user: User) -> Unit>()
private val commands = mutableMapOf<String, (msg: SessionChatMessage, user: User) -> Unit>()
private val counterPattern = Regex("<counter:([^>]+)>")
private val personalCounterPattern = Regex("<counter_personal:([^>]+)>")
@@ -43,7 +43,7 @@ class MessageHandler(
if(it.type == SongType.STREAM_OFF) {
val user = UserService.getUser(channel.channelId)
if(! user?.let { usr -> SongListService.getSong(usr) }.isNullOrEmpty()) {
SongListService.deleteUser(user!!)
SongListService.deleteUser(user)
}
}
}
@@ -54,13 +54,14 @@ class MessageHandler(
?: 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::songAddCommand,
"!노래목록" to this::songListCommand,
"!노래시작" to this::songStartCommand
"!노래시작" to this::songStartCommand,
)
manageCommands.forEach { (commandName, command) ->
@@ -71,81 +72,90 @@ class MessageHandler(
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)
val result = replaceCounters(
Pair(it.content, it.failContent),
user,
msg,
msg.profile?.nickname ?: ""
)
handler.sendChat(result)
}
}
}
private fun manageAddCommand(msg: ChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 명령어를 추가할 수 있습니다.")
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) {
listener.sendChat("명령어 추가 형식은 '!명령어추가 명령어 내용'입니다.")
handler.sendChat("명령어 추가 형식은 '!명령어추가 명령어 내용'입니다.")
return
}
if (commands.containsKey(parts[1])) {
listener.sendChat("${parts[1]} 명령어는 이미 있는 명령어입니다.")
handler.sendChat("${parts[1]} 명령어는 이미 있는 명령어입니다.")
return
}
val command = parts[1]
val content = parts[2]
CommandService.saveCommand(user, command, content, "")
listener.sendChat("명령어 '$command' 추가되었습니다.")
handler.sendChat("명령어 '$command' 추가되었습니다.")
}
private fun manageUpdateCommand(msg: ChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 명령어를 추가할 수 있습니다.")
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) {
listener.sendChat("명령어 수정 형식은 '!명령어수정 명령어 내용'입니다.")
handler.sendChat("명령어 수정 형식은 '!명령어수정 명령어 내용'입니다.")
return
}
if (!commands.containsKey(parts[1])) {
listener.sendChat("${parts[1]} 명령어는 없는 명령어입니다.")
handler.sendChat("${parts[1]} 명령어는 없는 명령어입니다.")
return
}
val command = parts[1]
val content = parts[2]
CommandService.updateCommand(user, command, content, "")
listener.sendChat("명령어 '$command' 수정되었습니다.")
handler.sendChat("명령어 '$command' 수정되었습니다.")
ChzzkHandler.reloadCommand(channel)
}
private fun manageRemoveCommand(msg: ChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 명령어를 삭제할 수 있습니다.")
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) {
listener.sendChat("명령어 삭제 형식은 '!명령어삭제 명령어'입니다.")
handler.sendChat("명령어 삭제 형식은 '!명령어삭제 명령어'입니다.")
return
}
val command = parts[1]
CommandService.removeCommand(user, command)
listener.sendChat("명령어 '$command' 삭제되었습니다.")
handler.sendChat("명령어 '$command' 삭제되었습니다.")
ChzzkHandler.reloadCommand(channel)
}
private fun timerCommand(msg: ChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
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) {
listener.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!")
handler.sendChat("타이머 명령어 형식을 잘 찾아봐주세요!")
return
}
@@ -174,13 +184,13 @@ class MessageHandler(
when (parts[2]) {
"업타임" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.UPTIME)
listener.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.")
handler.sendChat("기본 타이머 설정이 업타임으로 바뀌었습니다.")
}
"삭제" -> {
TimerConfigService.saveOrUpdateConfig(user, TimerType.REMOVE)
listener.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.")
handler.sendChat("기본 타이머 설정이 삭제로 바뀌었습니다.")
}
else -> listener.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!")
else -> handler.sendChat("!타이머 설정 (업타임/삭제) 형식으로 써주세요!")
}
}
else -> {
@@ -194,9 +204,9 @@ class MessageHandler(
dispatcher.post(TimerEvent(user.token, TimerType.TIMER, timestamp.toString()))
}
} catch (e: NumberFormatException) {
listener.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분")
handler.sendChat("!타이머/숫자 형식으로 적어주세요! 단위: 분")
} catch (e: Exception) {
listener.sendChat("타이머 설정 중 오류가 발생했습니다.")
handler.sendChat("타이머 설정 중 오류가 발생했습니다.")
logger.error("Error processing timer command: ${e.message}", e)
}
}
@@ -204,17 +214,21 @@ class MessageHandler(
}
// songs
private fun songAddCommand(msg: ChatMessage, user: User) {
val parts = msg.content.split(" ", limit = 3)
private fun songAddCommand(msg: SessionChatMessage, user: User) {
if(SongConfigService.getConfig(user).disabled) {
return
}
val parts = msg.content.split(" ", limit = 2)
if (parts.size < 2) {
listener.sendChat("유튜브 URL을 입력해주세요!")
handler.sendChat("유튜브 URL을 입력해주세요!")
return
}
val config = SongConfigService.getConfig(user)
if(config.streamerOnly && msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
if(config.streamerOnly && msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return
}
@@ -222,29 +236,34 @@ class MessageHandler(
val songs = SongListService.getSong(user)
if(songs.size >= config.queueLimit) {
listener.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return
}
if(songs.filter { it.uid == msg.userId }.size >= config.personalLimit) {
listener.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
if(songs.filter { it.uid == msg.senderChannelId }.size >= config.personalLimit) {
handler.sendChat("더이상 노래를 신청할 수 없습니다. 잠시 뒤 다시 시도해주세요!")
return
}
try {
val video = getYoutubeVideo(url)
if (video == null) {
listener.sendChat("유튜브에서 찾을 수 없어요!")
handler.sendChat("유튜브에서 찾을 수 없어요!")
return
}
if (songs.any { it.url == video.url }) {
listener.sendChat("같은 노래가 이미 신청되어 있습니다.")
handler.sendChat("같은 노래가 이미 신청되어 있습니다.")
return
}
if (video.length > 600) {
handler.sendChat("10분이 넘는 노래는 신청할 수 없습니다.")
return
}
SongListService.saveSong(
user,
msg.userId,
msg.senderChannelId,
video.url,
video.name,
video.author,
@@ -256,64 +275,65 @@ class MessageHandler(
SongEvent(
user.token,
SongType.ADD,
msg.userId,
msg.profile?.nickname ?: "",
video.name,
video.author,
video.length,
video.url
msg.senderChannelId,
null,
video,
)
)
}
listener.sendChat("노래가 추가되었습니다.")
handler.sendChat("노래가 추가되었습니다. ${video.name} - ${video.author}")
} catch(e: Exception) {
listener.sendChat("유튜브 영상 주소로 다시 신청해주세요!")
handler.sendChat("유튜브 영상 주소로 다시 신청해주세요!")
logger.info(e.stackTraceToString())
}
}
private fun songListCommand(msg: ChatMessage, user: User) {
listener.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}")
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: ChatMessage, user: User) {
if (msg.profile?.userRoleCode == "common_user") {
listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
private fun songStartCommand(msg: SessionChatMessage, user: User) {
if (msg.profile.badges.none { it.imageUrl.contains("manager") }) {
handler.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
return
}
val session = "${UUID.randomUUID()}${UUID.randomUUID()}".replace("-", "")
SongConfigService.updateSession(user, session)
bot.retrieveUserById(user.discord).queue { discordUser ->
discordUser?.openPrivateChannel()?.queue { channel ->
channel.sendMessage("여기로 접속해주세요! ||https://nabot.mori.space/songlist/${session}||.\n주소가 노출될 경우 방송을 다시 켜셔야 합니다!")
.queue()
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: ChatMessage, user: User) {
if(msg.userId == ChzzkHandler.botUid) return
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: ChatMessage, listener: ChzzkChat, userName: String): String {
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.userId, user)
val dailyCounter = CounterService.getDailyCounterValue(name, msg.senderChannelId, user)
if (dailyCounter.second) {
CounterService.updateDailyCounterValue(name, msg.userId, 1, user).first.toString()
CounterService.updateDailyCounterValue(name, msg.senderChannelId, 1, user).first.toString()
} else {
isFail = true
dailyCounter.first.toString()
@@ -325,7 +345,7 @@ class MessageHandler(
result = chat.second
result = dailyCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1]
val dailyCounter = CounterService.getDailyCounterValue(name, msg.userId, user)
val dailyCounter = CounterService.getDailyCounterValue(name, msg.senderChannelId, user)
dailyCounter.first.toString()
}
}
@@ -333,15 +353,15 @@ class MessageHandler(
// Replace followPattern
result = followPattern.replace(result) { _ ->
try {
val followingDate = getFollowDate(listener.chatId, msg.userId)
.content?.streamingProperty?.following?.followDate
val followingDate = handler.chatChannelId?.let { getFollowDate(it, msg.senderChannelId) }
?.content?.streamingProperty?.following?.followDate ?: LocalDateTime.now().minusDays(1).toString()
val period = followingDate?.let {
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
} + 1
period.toString()
} catch (e: Exception) {
@@ -369,7 +389,7 @@ class MessageHandler(
// Replace personalCounterPattern
result = personalCounterPattern.replace(result) { matchResult ->
val name = matchResult.groupValues[1]
CounterService.updatePersonalCounterValue(name, msg.userId, 1, user).toString()
CounterService.updatePersonalCounterValue(name, msg.senderChannelId, 1, user).toString()
}
// Replace namePattern
@@ -377,5 +397,4 @@ class MessageHandler(
return result
}
}
}

View File

@@ -8,17 +8,16 @@ 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.guild.member.GuildMemberRemoveEvent
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.common.utils.IData
import space.mori.chzzk_bot.common.utils.IStreamInfo
import space.mori.chzzk_bot.chatbot.discord.commands.*
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.services.ManagerService
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
@@ -31,21 +30,30 @@ class Discord: ListenerAdapter() {
companion object {
lateinit var bot: JDA
internal fun getChannel(guildId: Long, channelId: Long): TextChannel? = bot.getGuildById(guildId)?.getTextChannelById(channelId)
internal fun getChannel(guildId: Long, channelId: Long): TextChannel? {
return bot.getGuildById(guildId)?.getTextChannelById(channelId)
}
fun sendDiscord(user: User, status: IData<IStreamInfo?>) {
if(status.content == null) return
if(user.liveAlertMessage != "" && user.liveAlertGuild != null && user.liveAlertChannel != null) {
val channel = getChannel(user.liveAlertGuild!!, user.liveAlertChannel!!) ?: throw RuntimeException("${user.liveAlertChannel} is not valid.")
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.content!!.liveTitle, "https://chzzk.naver.com/live/${user.token}")
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}", 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"))
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()
@@ -58,15 +66,7 @@ class Discord: ListenerAdapter() {
}
private val commands = listOf(
AddCommand,
AlertCommand,
PingCommand,
RegisterCommand,
RemoveCommand,
UpdateCommand,
AddManagerCommand,
ListManagerCommand,
RemoveManagerCommand,
)
override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) {
@@ -76,10 +76,6 @@ class Discord: ListenerAdapter() {
handler?.run(event, bot)
}
override fun onGuildMemberRemove(event: GuildMemberRemoveEvent) {
event.member?.let { ManagerService.deleteManager(event.guild.idLong, it.idLong) }
}
override fun onGuildJoin(event: GuildJoinEvent) {
commandUpdate(event.guild)
}

View File

@@ -1,73 +0,0 @@
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.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.CommandService
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.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 (manager != null) {
transaction {
user = manager.user
}
user?.let { ManagerService.updateManager(it, event.user.idLong, event.user.effectiveName) }
}
if (user == null) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
return
}
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.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.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.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.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,59 +0,0 @@
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.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.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 (manager != null) {
transaction {
user = manager.user
}
user?.let { ManagerService.updateManager(it, event.user.idLong, event.user.effectiveName) }
}
if (user == null) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
return
}
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.chatbot.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.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.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,60 +0,0 @@
package space.mori.chzzk_bot.chatbot.discord.commands
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
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.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.UserService
object RegisterCommand: CommandInterface {
private val logger = LoggerFactory.getLogger(this::class.java)
override val name = "register"
private val regex = """(?:.+chzzk\.naver\.com/)?([a-f0-9]{32})?(?:/live)?${'$'}""".toRegex()
override val command = Commands.slash(name, "치지직 계정을 등록합니다.")
.addOptions(
OptionData(
OptionType.STRING,
"chzzk_id",
"치지직 채널 URL 혹은 ID를 입력해주세요.",
true
)
)
override fun run(event: SlashCommandInteractionEvent, bot: JDA) {
val chzzkID = event.getOption("chzzk_id")?.asString
if(chzzkID == null) {
event.hook.sendMessage("치지직 계정은 필수 입력입니다.").queue()
return
}
val matchResult = regex.find(chzzkID)
val matchedChzzkId = matchResult?.groups?.get(1)?.value
val chzzkChannel = matchedChzzkId?.let { Connector.getChannel(it) }
if (chzzkChannel == null) {
event.hook.sendMessage("치지직 계정을 찾을 수 없습니다.").queue()
return
}
try {
val user = UserService.saveUser(chzzkChannel.channelName, chzzkChannel.channelId, event.user.idLong)
CoroutineScope(Dispatchers.Main).launch {
ChzzkHandler.addUser(chzzkChannel, user)
}
event.hook.sendMessage("등록이 완료되었습니다. `${chzzkChannel.channelId}` - `${chzzkChannel.channelName}`")
} catch(e: Exception) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
logger.debug(e.stackTraceToString())
}
}
}

View File

@@ -1,63 +0,0 @@
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.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.CommandService
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.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 (manager != null) {
transaction {
user = manager.user
}
user?.let { ManagerService.updateManager(it, event.user.idLong, event.user.effectiveName) }
}
if (user == null) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
return
}
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.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.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.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.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,65 +0,0 @@
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.OptionType
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.OptionData
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
import space.mori.chzzk_bot.chatbot.chzzk.ChzzkHandler
import space.mori.chzzk_bot.chatbot.chzzk.Connector
import space.mori.chzzk_bot.chatbot.discord.CommandInterface
import space.mori.chzzk_bot.common.services.CommandService
import space.mori.chzzk_bot.common.services.ManagerService
import space.mori.chzzk_bot.common.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 (manager != null) {
transaction {
user = manager.user
}
user?.let { ManagerService.updateManager(it, event.user.idLong, event.user.effectiveName) }
}
if (user == null) {
event.hook.sendMessage("에러가 발생했습니다.").queue()
return
}
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

@@ -0,0 +1,55 @@
package space.mori.chzzk_bot.chatbot.utils
import com.google.gson.Gson
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
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://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 this.apiClientId,
"clientSecret" to this.apiSecret
)).toRequestBody())
.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
}
}
}

View File

@@ -11,25 +11,25 @@ repositories {
dependencies {
// https://mvnrepository.com/artifact/org.jetbrains.exposed/exposed-core
api("org.jetbrains.exposed:exposed-core:0.52.0")
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.52.0")
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.52.0")
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.52.0")
api("org.jetbrains.exposed:exposed-java-time:0.56.0")
// https://mvnrepository.com/artifact/com.zaxxer/HikariCP
api("com.zaxxer:HikariCP:5.1.0")
api("com.zaxxer:HikariCP:6.1.0")
// https://mvnrepository.com/artifact/ch.qos.logback/logback-classic
implementation("ch.qos.logback:logback-classic:1.5.6")
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.4.1")
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.1")
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")

View File

@@ -25,16 +25,17 @@ object Connector {
init {
Database.connect(dataSource)
val tables = listOf(
UserManagers,
Users,
Commands,
Counters,
DailyCounters,
PersonalCounters,
Managers,
TimerConfigs,
LiveStatuses,
SongLists,
SongConfigs
SongConfigs,
Sessions
)
transaction {

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

@@ -1,22 +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)
STREAM_OFF(50),
ACK(51)
}
class SongEvent(
val uid: String,
val type: SongType,
val reqUid: String?,
val reqName: String?,
val name: String?,
val author: String?,
val time: Int?,
val url: String?
val current: YoutubeVideo? = null,
val next: YoutubeVideo? = null,
val delUrl: String? = null,
): Event {
var TAG = javaClass.simpleName
}

View File

@@ -5,7 +5,8 @@ enum class TimerType(var value: Int) {
TIMER(1),
REMOVE(2),
STREAM_OFF(50)
STREAM_OFF(50),
ACK(51)
}
class TimerEvent(

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,22 +0,0 @@
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 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

@@ -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

@@ -12,6 +12,7 @@ object SongConfigs: IntIdTable("song_config") {
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)
@@ -21,4 +22,5 @@ class SongConfig(id: EntityID<Int>) : IntEntity(id) {
var streamerOnly by SongConfigs.streamerOnly
var queueLimit by SongConfigs.queueLimit
var personalLimit by SongConfigs.personalLimit
var disabled by SongConfigs.disabled
}

View File

@@ -12,7 +12,7 @@ object SongLists: IntIdTable("song_list") {
val uid = varchar("uid", 64)
val url = varchar("url", 128)
val name = text("name")
val reqName = varchar("req_name", 20)
val reqName = varchar("req_name", 80)
val author = text("author")
val time = integer("time")
val created_at = datetime("created_at").default(LocalDateTime.now())

View File

@@ -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,79 +0,0 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.dao.load
import org.jetbrains.exposed.dao.with
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.Manager
import space.mori.chzzk_bot.common.models.Managers
import space.mori.chzzk_bot.common.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 {
return transaction {
if (user.liveAlertGuild == null)
throw RuntimeException("${user.username} has no liveAlertGuild")
val manager = getUser(user.liveAlertGuild!!, discordId) ?: throw RuntimeException("$name isn't manager.")
manager.lastUserName = name
manager
}
}
fun getUser(guildId: Long, discordId: Long): Manager? {
return transaction {
val manager = Manager.find(
(Managers.discordGuildId eq guildId) and (Managers.managerId eq discordId),
)
.with(Manager::user)
.firstOrNull()
manager
}
}
fun getAllUsers(guildId: Long): List<Manager> {
return transaction {
val result = Manager.find(Managers.discordGuildId eq guildId)
.with(Manager::user)
.toList()
result.forEach { it.load(Manager::user) }
result
}
}
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

@@ -26,19 +26,6 @@ object SongConfigService {
}
fun getConfig(token: String): SongConfig? {
return transaction {
SongConfig.find(SongConfigs.token eq token).firstOrNull()
}
}
fun getUserByToken(token: String): User? {
return transaction {
val songConfig = SongConfig.find(SongConfigs.token eq token).firstOrNull()
if(songConfig == null) null
else UserService.getUser(songConfig.user.discord)
}
}
fun updatePersonalLimit(user: User, limit: Int): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
@@ -60,18 +47,6 @@ object SongConfigService {
}
}
fun updateSession(user: User, token: String?): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
if (songConfig == null) {
songConfig = initConfig(user)
}
songConfig.token = token
songConfig
}
}
fun updateStreamerOnly(user: User, config: Boolean): SongConfig {
return transaction {
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
@@ -83,4 +58,16 @@ object SongConfigService {
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

@@ -1,49 +1,66 @@
package space.mori.chzzk_bot.common.services
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.dao.load
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import space.mori.chzzk_bot.common.models.User
import space.mori.chzzk_bot.common.models.Users
object UserService {
fun saveUser(username: String, token: String, discordID: Long): User {
fun saveUser(username: String, token: String): User {
return transaction {
User.new {
this.username = username
this.token = token
this.discord = discordID
}
}
}
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 {
User.findById(id)
val user = User.find{ Users.id eq id }.firstOrNull()
user?.load(User::subordinates, User::managers)
user
}
}
fun getUser(discordID: Long): User? {
return transaction {
val users = User.find(Users.discord eq discordID)
users.firstOrNull()
val user = User.find{ Users.discord eq discordID }.firstOrNull()
user?.load(User::subordinates, User::managers)
user
}
}
fun getUser(chzzkID: String): User? {
return transaction {
val users = User.find(Users.token eq chzzkID)
users.firstOrNull()
val user = User.find{ Users.token eq chzzkID }.firstOrNull()
user?.load(User::subordinates, User::managers)
user
}
}
fun getUserWithGuildId(discordGuildId: Long): User? {
return transaction {
val users = User.find(Users.liveAlertGuild eq discordGuildId)
users.firstOrNull()
val user = User.find { Users.liveAlertGuild eq discordGuildId }.firstOrNull()
user?.load(User::subordinates, User::managers)
user
}
}
@@ -53,18 +70,46 @@ object UserService {
}
}
fun updateLiveAlert(id: Int, guildId: Long, channelId: Long, alertMessage: String?): User {
fun updateLiveAlert(user: User, 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 ?: ""
}
user.liveAlertGuild = guildId
user.liveAlertChannel = channelId
user.liveAlertMessage = alertMessage ?: ""
if(updated == 0) throw RuntimeException("User not found! $id")
val users = User.find { Users.id eq id }
user.load(User::subordinates, User::managers)
return@transaction users.first()
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

@@ -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 추가
@@ -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/v3/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

@@ -15,7 +15,7 @@ data class YoutubeVideo(
val length: Int
)
val regex = ".*(?:youtu.be/|v/|u/\\w/|embed/|watch\\?v=|&v=)([^#&?]*).*".toRegex()
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 {

View File

@@ -6,5 +6,9 @@ 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

@@ -10,7 +10,7 @@ repositories {
mavenCentral()
}
val ktorVersion = "2.3.12"
val ktorVersion = "3.1.3"
dependencies {
implementation("io.ktor:ktor-server-core:$ktorVersion")
@@ -18,22 +18,30 @@ dependencies {
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-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-server-cors:$ktorVersion")
implementation("io.ktor:ktor-server-swagger:$ktorVersion")
implementation("io.ktor:ktor-server-auth:$ktorVersion")
implementation("io.swagger.codegen.v3:swagger-codegen-generators:1.0.50")
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-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/io.insert-koin/koin-core
implementation("io.insert-koin:koin-core:4.0.0-RC1")
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.6")
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"))

View File

@@ -1,24 +1,49 @@
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 server = embeddedServer(Netty, port = 8080) {
val dotenv = dotenv {
ignoreIfMissing = true
}
val redirects = mutableMapOf<String, String>()
val server = embeddedServer(Netty, port = 8080, ) {
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(15)
pingPeriod = Duration.ofSeconds(15).toKotlinDuration()
timeout = Duration.ofSeconds(100).toKotlinDuration()
maxFrameSize = Long.MAX_VALUE
masking = false
contentConverter = KotlinxWebsocketSerializationConverter(Json)
@@ -30,22 +55,212 @@ val server = embeddedServer(Netty, port = 8080) {
isLenient = true
})
}
install(CORS) {
anyHost()
allowHeader(HttpHeaders.ContentType)
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://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() {
@@ -54,4 +269,179 @@ fun start() {
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

@@ -1,12 +1,20 @@
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 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.utils.getStreamInfo
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(
@@ -25,13 +33,30 @@ data class GetSessionDTO(
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)
HttpStatusCode.OK)
}
}
route("/health") {
@@ -42,59 +67,83 @@ fun Routing.apiRoutes() {
route("/user/{uid}") {
get {
val uid = call.parameters["uid"]
val uid = call.parameters["uid"]
if(uid == null) {
call.respondText("Require UID", status = HttpStatusCode.NotFound)
return@get
}
val user = getStreamInfo(uid)
if(user.content == null) {
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.content!!.channel.channelId,
user.content!!.channel.channelName,
user.content!!.status == "OPEN",
user.content!!.channel.channelImageUrl
user?.uid ?: "",
user?.nickname ?: "",
user?.isStreamOn ?: false,
user?.avatarUrl ?: ""
))
}
}
}
route("/user") {
get {
call.respondText("Require UID", status = HttpStatusCode.NotFound)
}
}
route("/session/{sid}") {
get {
val sid = call.parameters["sid"]
if(sid == null) {
call.respondText("Require SID", status = HttpStatusCode.NotFound)
val session = call.sessions.get<UserSession>()
if(session == null) {
call.respondText("No session found", status = HttpStatusCode.Unauthorized)
return@get
}
val user = SongConfigService.getUserByToken(sid)
val session = SongConfigService.getConfig(sid)
var user = UserService.getUser(session.id)
if(user == null) {
call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get
} else {
val chzzkUser = getStreamInfo(user.token)
call.respond(HttpStatusCode.OK, GetSessionDTO(
chzzkUser.content!!.channel.channelId,
chzzkUser.content!!.channel.channelName,
chzzkUser.content!!.status == "OPEN",
chzzkUser.content!!.channel.channelImageUrl,
session!!.queueLimit,
session.personalLimit,
session.streamerOnly
))
user = UserService.saveUser("임시닉네임", session.id)
}
}
}
route("/session") {
get {
call.respondText("Require SID", status = HttpStatusCode.NotFound)
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

@@ -8,6 +8,8 @@ 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(
@@ -18,6 +20,12 @@ data class SongsDTO(
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,
@@ -26,6 +34,14 @@ fun SongList.toDTO(): SongsDTO = SongsDTO(
this.reqName
)
fun YoutubeVideo.toDTO(): SongsDTO = SongsDTO(
this.url,
this.name,
this.author,
this.length,
""
)
fun Routing.apiSongRoutes() {
route("/songs/{uid}") {
get {
@@ -37,7 +53,12 @@ fun Routing.apiSongRoutes() {
}
val songs = SongListService.getSong(user)
call.respond(HttpStatusCode.OK, songs.map { it.toDTO() })
call.respond(HttpStatusCode.OK,
SongsResponseDTO(
CurrentSong.getSong(user)?.toDTO(),
songs.map { it.toDTO() }
)
)
}
}
route("/songs") {

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

@@ -1,210 +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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.launch
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.Counters.withDefinition
import space.mori.chzzk_bot.common.services.SongConfigService
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
import java.util.concurrent.ConcurrentLinkedQueue
fun Routing.wsSongListRoutes() {
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
val status = ConcurrentHashMap<String, SongType>()
val logger = LoggerFactory.getLogger("WSSongListRoutes")
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
val songListScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun addSession(sid: String, session: WebSocketServerSession) {
sessions.computeIfAbsent(sid) { ConcurrentLinkedQueue() }.add(session)
}
// Manage all active sessions
val sessionHandlers = ConcurrentHashMap<String, SessionHandler>()
fun removeSession(sid: String, session: WebSocketServerSession) {
sessions[sid]?.remove(session)
if(sessions[sid]?.isEmpty() == true) {
sessions.remove(sid)
}
}
webSocket("/songlist/{sid}") {
val sid = call.parameters["sid"]
val session = sid?.let { SongConfigService.getConfig(it) }
val user = sid?.let {SongConfigService.getUserByToken(sid) }
if (sid == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID"))
return@webSocket
}
if (user == null || session == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID"))
return@webSocket
}
addSession(sid, this)
if(status[sid] == SongType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch {
sendSerialized(SongResponse(
SongType.STREAM_OFF.value,
user.token,
null,
null,
null,
null,
null
))
// Handle application shutdown
environment.monitor.subscribe(ApplicationStopped) {
sessionHandlers.values.forEach {
songListScope.launch {
it.close(CloseReason(CloseReason.Codes.NORMAL, "Server shutting down"))
}
removeSession(sid, this)
}
}
// 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 -> {
val data = frame.readText().let { Json.decodeFromString<SongRequest>(it) }
if(data.maxQueue != null && data.maxQueue > 0) SongConfigService.updateQueueLimit(user, data.maxQueue)
if(data.maxUserLimit != null && data.maxUserLimit > 0) SongConfigService.updatePersonalLimit(user, data.maxUserLimit)
if(data.isStreamerOnly != null) SongConfigService.updateStreamerOnly(user, data.isStreamerOnly)
if(data.type == SongType.ADD.value && data.url != null) {
try {
val youtubeVideo = getYoutubeVideo(data.url)
if (youtubeVideo != null) {
CoroutineScope(Dispatchers.Default).launch {
SongListService.saveSong(
user,
user.token,
data.url,
youtubeVideo.name,
youtubeVideo.author,
youtubeVideo.length,
user.username
)
dispatcher.post(
SongEvent(
user.token,
SongType.ADD,
user.token,
user.username,
youtubeVideo.name,
youtubeVideo.author,
youtubeVideo.length,
youtubeVideo.url
)
)
}
}
} catch(e: Exception) {
logger.debug("SongType.ADD Error: {} / {}", session.token, e)
}
}
else if(data.type == SongType.REMOVE.value && data.url != null) {
dispatcher.post(SongEvent(
user.token,
SongType.REMOVE,
null,
null,
null,
null,
0,
data.url
))
} else if(data.type == SongType.NEXT.value) {
val song = SongListService.getSong(user)[0]
SongListService.deleteSong(user, song.uid, song.name)
dispatcher.post(SongEvent(
user.token,
SongType.NEXT,
null,
null,
null,
null,
null,
null
))
}
}
when (frame) {
is Frame.Text -> handler.handleTextFrame(frame.readText())
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {
}
else -> Unit
}
}
} catch(e: ClosedReceiveChannelException) {
logger.error("Error in WebSocket: ${e.message}")
} 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 {
removeSession(sid, this)
sessionHandlers.remove(uid)
handler.close(CloseReason(CloseReason.Codes.NORMAL, "Session ended"))
}
}
dispatcher.subscribe(SongEvent::class) {
logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.name)
CoroutineScope(Dispatchers.Default).launch {
val user = UserService.getUser(it.uid)
if(user != null) {
val session = SongConfigService.getConfig(user)
sessions[session.token ?: ""]?.forEach { ws ->
ws.sendSerialized(
SongResponse(
it.type.value,
it.uid,
it.reqUid,
it.name,
it.author,
it.time,
it.url
)
)
}
// 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()
}
}
}
dispatcher.subscribe(TimerEvent::class) {
if(it.type == TimerType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch {
val user = UserService.getUser(it.uid)
if(user != null) {
val session = SongConfigService.getConfig(user)
}
sessions[session.token ?: ""]?.forEach { ws ->
ws.sendSerialized(
SongResponse(
it.type.value,
it.uid,
null,
null,
null,
null,
null
)
)
removeSession(session.token ?: "", ws)
}
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?,
val maxQueue: Int?,
val maxUserLimit: Int?,
val isStreamerOnly: Boolean?,
val remove: Int?
)
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

@@ -1,24 +1,38 @@
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)
@@ -26,102 +40,174 @@ fun Routing.wsSongRoutes() {
fun removeSession(uid: String, session: WebSocketServerSession) {
sessions[uid]?.remove(session)
if(sessions[uid]?.isEmpty() == true) {
if (sessions[uid]?.isEmpty() == true) {
sessions.remove(uid)
}
}
webSocket("/song/{uid}") {
val uid = call.parameters["uid"]
val user = uid?.let { UserService.getUser(it) }
if (uid == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
if (user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
addSession(uid, this)
if(status[uid] == SongType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch {
sendSerialized(SongResponse(
SongType.STREAM_OFF.value,
uid,
null,
null,
null,
null,
null
))
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 {
for (frame in incoming) {
when(frame) {
is Frame.Text -> {
}
is Frame.Ping -> send(Frame.Pong(frame.data))
else -> {
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
}
}
}
} catch(e: ClosedReceiveChannelException) {
logger.error("Error in WebSocket: ${e.message}")
} finally {
removeSession(uid, this)
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)
}
}
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
dispatcher.subscribe(SongEvent::class) {
logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.name)
CoroutineScope(Dispatchers.Default).launch {
sessions[it.uid]?.forEach { ws ->
ws.sendSerialized(SongResponse(
logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.current?.name)
songScope.launch {
broadcastMessage(
it.uid, SongResponse(
it.type.value,
it.uid,
it.reqUid,
it.name,
it.author,
it.time,
it.url
))
}
it.current?.toSerializable(),
it.next?.toSerializable(),
it.delUrl
)
)
}
}
dispatcher.subscribe(TimerEvent::class) {
if(it.type == TimerType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch {
sessions[it.uid]?.forEach { ws ->
ws.sendSerialized(SongResponse(
if (it.type == TimerType.STREAM_OFF) {
songScope.launch {
broadcastMessage(
it.uid, SongResponse(
it.type.value,
it.uid,
null,
null,
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 name: String?,
val author: String?,
val time: Int?,
val url: String?
val current: SerializableYoutubeVideo? = null,
val next: SerializableYoutubeVideo? = null,
val delUrl: String? = null
)

View File

@@ -1,30 +1,42 @@
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 status = ConcurrentHashMap<String, TimerType>()
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) {
@@ -32,67 +44,132 @@ fun Routing.wsTimerRoutes() {
}
}
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) {
if (uid == null || user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
if (user == null) {
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid UID"))
return@webSocket
}
addSession(uid, this)
val timer = CurrentTimer.getTimer(user)
if(status[uid] == TimerType.STREAM_OFF) {
CoroutineScope(Dispatchers.Default).launch {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null))
if (timer?.type == TimerType.STREAM_OFF) {
timerScope.launch {
sendSerialized(TimerResponse(TimerType.STREAM_OFF.value, null, uid))
}
} else {
CoroutineScope(Dispatchers.Default).launch {
sendSerialized(TimerResponse(
TimerConfigService.getConfig(user)?.option ?: TimerType.REMOVE.value,
null
))
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 -> {
}
else -> {}
}
}
} catch(e: ClosedReceiveChannelException) {
logger.error("Error in WebSocket: ${e.message}")
} finally {
removeSession(uid, this)
ackMap[uid]?.remove(this)
}
}
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
dispatcher.subscribe(TimerEvent::class) {
logger.debug("TimerEvent: {} / {}", it.uid, it.type)
status[it.uid] = it.type
CoroutineScope(Dispatchers.Default).launch {
sessions[it.uid]?.forEach { ws ->
ws.sendSerialized(TimerResponse(it.type.value, it.time ?: ""))
}
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 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,
)