koishi-plugin-steam-info-check 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,304 @@
1
+ import { Context, Schema, Logger, Service, Session, h } from 'koishi'
2
+ import { SteamService } from './service'
3
+ import { DrawService } from './drawer'
4
+ import { SteamBind, SteamChannel } from './database'
5
+
6
+ export const name = 'steam-info'
7
+ export const inject = ['model', 'http', 'puppeteer', 'database']
8
+
9
+ export interface Config {
10
+ steamApiKey: string[]
11
+ proxy?: string
12
+ steamRequestInterval: number
13
+ steamBroadcastType: 'all' | 'part' | 'none'
14
+ steamDisableBroadcastOnStartup: boolean
15
+ fonts: {
16
+ regular: string
17
+ light: string
18
+ bold: string
19
+ }
20
+ commandAuthority: {
21
+ bind: number
22
+ unbind: number
23
+ info: number
24
+ check: number
25
+ enable: number
26
+ disable: number
27
+ update: number
28
+ nickname: number
29
+ }
30
+ }
31
+
32
+ export const Config: Schema<Config> = Schema.object({
33
+ steamApiKey: Schema.array(String).required().description('Steam API Key (supports multiple)'),
34
+ proxy: Schema.string().description('Proxy URL (e.g., http://127.0.0.1:7890)'),
35
+ steamRequestInterval: Schema.number().default(300).description('Polling interval in seconds'),
36
+ steamBroadcastType: Schema.union(['all', 'part', 'none']).default('part').description('Broadcast type: all (list), part (gaming only), none (text only)'),
37
+ steamDisableBroadcastOnStartup: Schema.boolean().default(false).description('Disable broadcast on startup'),
38
+ fonts: Schema.object({
39
+ regular: Schema.string().default('fonts/MiSans-Regular.ttf'),
40
+ light: Schema.string().default('fonts/MiSans-Light.ttf'),
41
+ bold: Schema.string().default('fonts/MiSans-Bold.ttf'),
42
+ }).description('Font paths relative to the plugin resource directory or absolute paths'),
43
+ commandAuthority: Schema.object({
44
+ bind: Schema.number().default(1).description('Authority for bind command'),
45
+ unbind: Schema.number().default(1).description('Authority for unbind command'),
46
+ info: Schema.number().default(1).description('Authority for info command'),
47
+ check: Schema.number().default(1).description('Authority for check command'),
48
+ enable: Schema.number().default(2).description('Authority for enable broadcast'),
49
+ disable: Schema.number().default(2).description('Authority for disable broadcast'),
50
+ update: Schema.number().default(2).description('Authority for update group info'),
51
+ nickname: Schema.number().default(1).description('Authority for nickname command'),
52
+ }).description('Command Authorities'),
53
+ })
54
+
55
+ export const logger = new Logger('steam-info')
56
+
57
+ declare module 'koishi' {
58
+ interface Context {
59
+ steam: SteamService
60
+ drawer: DrawService
61
+ }
62
+ }
63
+
64
+ export function apply(ctx: Context, config: Config) {
65
+ // Localization
66
+ ctx.i18n.define('zh-CN', require('./locales/zh-CN'))
67
+
68
+ // Services
69
+ ctx.plugin(SteamService, config)
70
+ ctx.plugin(DrawService, config)
71
+
72
+ // Database
73
+ ctx.model.extend('steam_bind', {
74
+ id: 'unsigned',
75
+ userId: 'string',
76
+ channelId: 'string',
77
+ steamId: 'string',
78
+ nickname: 'string',
79
+ }, {
80
+ primary: 'id',
81
+ autoInc: true,
82
+ })
83
+
84
+ ctx.model.extend('steam_channel', {
85
+ id: 'string', // channelId
86
+ enable: 'boolean',
87
+ name: 'string',
88
+ avatar: 'string',
89
+ }, {
90
+ primary: 'id',
91
+ })
92
+
93
+ // Commands
94
+ ctx.command('steam', 'Steam Information')
95
+
96
+ ctx.command('steam.bind <steamId:string>', 'Bind Steam ID', { authority: config.commandAuthority.bind })
97
+ .alias('steambind', '绑定steam')
98
+ .action(async ({ session }, steamId) => {
99
+ if (!session) return
100
+ if (!steamId || !/^\d+$/.test(steamId)) return session.text('.invalid_id')
101
+
102
+ const targetId = await ctx.steam.getSteamId(steamId)
103
+ if (!targetId) return session.text('.id_not_found')
104
+
105
+ await ctx.database.upsert('steam_bind', [
106
+ {
107
+ userId: session.userId,
108
+ channelId: session.channelId,
109
+ steamId: targetId,
110
+ }
111
+ ], ['userId', 'channelId'])
112
+
113
+ return session.text('.bind_success', [targetId])
114
+ })
115
+
116
+ ctx.command('steam.unbind', 'Unbind Steam ID', { authority: config.commandAuthority.unbind })
117
+ .alias('steamunbind', '解绑steam')
118
+ .action(async ({ session }) => {
119
+ if (!session) return
120
+ const result = await ctx.database.remove('steam_bind', {
121
+ userId: session.userId,
122
+ channelId: session.channelId,
123
+ })
124
+ return result ? session.text('.unbind_success') : session.text('.not_bound')
125
+ })
126
+
127
+ ctx.command('steam.info [target:text]', 'View Steam Profile', { authority: config.commandAuthority.info })
128
+ .alias('steaminfo', 'steam信息')
129
+ .action(async ({ session }, target) => {
130
+ if (!session) return
131
+ let steamId: string | null = null
132
+ if (target) {
133
+ // Check if target is mention
134
+ const [platform, userId] = session.resolve(target)
135
+ if (userId) {
136
+ const bind = await ctx.database.get('steam_bind', { userId, channelId: session.channelId })
137
+ if (bind.length) steamId = bind[0].steamId
138
+ } else if (/^\d+$/.test(target)) {
139
+ steamId = await ctx.steam.getSteamId(target)
140
+ }
141
+ } else {
142
+ const bind = await ctx.database.get('steam_bind', { userId: session.userId, channelId: session.channelId })
143
+ if (bind.length) steamId = bind[0].steamId
144
+ }
145
+
146
+ if (!steamId) return session.text('.user_not_found')
147
+
148
+ const profile = await ctx.steam.getUserData(steamId)
149
+ const image = await ctx.drawer.drawPlayerStatus(profile, steamId)
150
+ if (typeof image === 'string') return session.send(image)
151
+ return session.send(h.image(image, 'image/png'))
152
+ })
153
+
154
+ ctx.command('steam.check', 'Check Friends Status', { authority: config.commandAuthority.check })
155
+ .alias('steamcheck', '查看steam', '查steam')
156
+ .action(async ({ session }) => {
157
+ if (!session) return
158
+ const binds = await ctx.database.get('steam_bind', { channelId: session.channelId })
159
+ if (binds.length === 0) return session.text('.no_binds')
160
+
161
+ const steamIds = binds.map(b => b.steamId)
162
+ const summaries = await ctx.steam.getPlayerSummaries(steamIds)
163
+
164
+ if (summaries.length === 0) return session.text('.api_error')
165
+
166
+ const channelInfo = await ctx.database.get('steam_channel', { id: session.channelId })
167
+ const parentAvatar = channelInfo[0]?.avatar
168
+ ? Buffer.from(channelInfo[0].avatar, 'base64')
169
+ : await ctx.drawer.getDefaultAvatar()
170
+ const parentName = channelInfo[0]?.name || session.channelId || 'Unknown'
171
+
172
+ const image = await ctx.drawer.drawFriendsStatus(parentAvatar, parentName, summaries, binds)
173
+ if (typeof image === 'string') return session.send(image)
174
+ return session.send(h.image(image, 'image/png'))
175
+ })
176
+
177
+ ctx.command('steam.enable', 'Enable Broadcast', { authority: config.commandAuthority.enable })
178
+ .alias('steamenable', '启用steam')
179
+ .action(async ({ session }) => {
180
+ if (!session) return
181
+ await ctx.database.upsert('steam_channel', [{ id: session.channelId, enable: true }])
182
+ return session.text('.enable_success')
183
+ })
184
+
185
+ ctx.command('steam.disable', 'Disable Broadcast', { authority: config.commandAuthority.disable })
186
+ .alias('steamdisable', '禁用steam')
187
+ .action(async ({ session }) => {
188
+ if (!session) return
189
+ await ctx.database.upsert('steam_channel', [{ id: session.channelId, enable: false }])
190
+ return session.text('.disable_success')
191
+ })
192
+
193
+ ctx.command('steam.update [name:string] [avatar:image]', 'Update Group Info', { authority: config.commandAuthority.update })
194
+ .alias('steamupdate', '更新群信息')
195
+ .action(async ({ session }, name, avatar) => {
196
+ if (!session) return
197
+ const img = session.elements && session.elements.find(e => e.type === 'img')
198
+ const imgUrl = img?.attrs?.src
199
+
200
+ let avatarBase64 = null
201
+ if (imgUrl) {
202
+ const buffer = await ctx.http.get(imgUrl, { responseType: 'arraybuffer' })
203
+ avatarBase64 = Buffer.from(buffer).toString('base64')
204
+ }
205
+
206
+ if (!name && !avatarBase64) return session.text('.args_missing')
207
+
208
+ const update: any = { id: session.channelId }
209
+ if (name) update.name = name
210
+ if (avatarBase64) update.avatar = avatarBase64
211
+
212
+ await ctx.database.upsert('steam_channel', [update])
213
+ return session.text('.update_success')
214
+ })
215
+
216
+ ctx.command('steam.nickname <nickname:string>', 'Set Steam Nickname', { authority: config.commandAuthority.nickname })
217
+ .alias('steamnickname', 'steam昵称')
218
+ .action(async ({ session }, nickname) => {
219
+ if (!session) return
220
+ const bind = await ctx.database.get('steam_bind', { userId: session.userId, channelId: session.channelId })
221
+ if (!bind.length) return session.text('.not_bound')
222
+
223
+ await ctx.database.upsert('steam_bind', [{ ...bind[0], nickname }])
224
+ return session.text('.nickname_set', [nickname])
225
+ })
226
+
227
+ // Scheduler
228
+ ctx.setInterval(async () => {
229
+ await broadcast(ctx)
230
+ }, config.steamRequestInterval * 1000)
231
+ }
232
+
233
+ // Broadcast Logic
234
+ const statusCache = new Map<string, any>()
235
+
236
+ async function broadcast(ctx: Context) {
237
+ const channels = await ctx.database.get('steam_channel', { enable: true })
238
+ if (channels.length === 0) return
239
+
240
+ const channelIds = channels.map(c => c.id)
241
+ const binds = await ctx.database.get('steam_bind', { channelId: channelIds })
242
+ if (binds.length === 0) return
243
+
244
+ const steamIds = [...new Set(binds.map(b => b.steamId))]
245
+
246
+ const currentSummaries = await ctx.steam.getPlayerSummaries(steamIds)
247
+ const currentMap = new Map(currentSummaries.map(p => [p.steamid, p]))
248
+
249
+ for (const channel of channels) {
250
+ const channelBinds = binds.filter(b => b.channelId === channel.id)
251
+ const msgs: string[] = []
252
+ const startGamingPlayers: any[] = []
253
+
254
+ for (const bind of channelBinds) {
255
+ const current = currentMap.get(bind.steamId)
256
+ const old = statusCache.get(bind.steamId)
257
+
258
+ if (!current) continue
259
+
260
+ if (!old) continue
261
+
262
+ const oldGame = old.gameextrainfo
263
+ const newGame = current.gameextrainfo
264
+ const name = bind.nickname || current.personaname
265
+
266
+ if (newGame && !oldGame) {
267
+ msgs.push(`${name} 开始玩 ${newGame} 了`)
268
+ startGamingPlayers.push({ ...current, nickname: bind.nickname })
269
+ } else if (!newGame && oldGame) {
270
+ msgs.push(`${name} 玩了 ${oldGame} 后不玩了`)
271
+ } else if (newGame && oldGame && newGame !== oldGame) {
272
+ msgs.push(`${name} 停止玩 ${oldGame},开始玩 ${newGame} 了`)
273
+ }
274
+ }
275
+
276
+ if (msgs.length > 0) {
277
+ const bot = ctx.bots[0]
278
+ if (!bot) continue
279
+
280
+ if (ctx.config.steamBroadcastType === 'none') {
281
+ await bot.sendMessage(channel.id, msgs.join('\n'))
282
+ } else if (ctx.config.steamBroadcastType === 'part') {
283
+ if (startGamingPlayers.length > 0) {
284
+ const images = await Promise.all(startGamingPlayers.map(p => ctx.drawer.drawStartGaming(p, p.nickname)))
285
+ const combined = await ctx.drawer.concatImages(images)
286
+ const img = combined ? (typeof combined === 'string' ? combined : h.image(combined, 'image/png')) : ''
287
+ await bot.sendMessage(channel.id, msgs.join('\n') + img)
288
+ } else {
289
+ await bot.sendMessage(channel.id, msgs.join('\n'))
290
+ }
291
+ } else if (ctx.config.steamBroadcastType === 'all') {
292
+ const channelPlayers = channelBinds.map(b => currentMap.get(b.steamId)).filter(Boolean) as any[]
293
+ const parentAvatar = channel.avatar ? Buffer.from(channel.avatar, 'base64') : await ctx.drawer.getDefaultAvatar()
294
+ const image = await ctx.drawer.drawFriendsStatus(parentAvatar, channel.name || channel.id, channelPlayers, channelBinds)
295
+ const img = image ? (typeof image === 'string' ? image : h.image(image, 'image/png')) : ''
296
+ await bot.sendMessage(channel.id, msgs.join('\n') + img)
297
+ }
298
+ }
299
+ }
300
+
301
+ for (const p of currentSummaries) {
302
+ statusCache.set(p.steamid, p)
303
+ }
304
+ }
@@ -0,0 +1,14 @@
1
+ invalid_id: "请输入有效的 Steam ID。"
2
+ id_not_found: "无法找到该 Steam ID。"
3
+ bind_success: "绑定成功!Steam ID: {0}"
4
+ unbind_success: "解绑成功。"
5
+ not_bound: "你还没有绑定 Steam ID。"
6
+ user_not_found: "未找到用户信息。"
7
+ error: "发生错误。"
8
+ no_binds: "本群尚无绑定用户。"
9
+ api_error: "连接 Steam API 失败。"
10
+ enable_success: "已开启本群播报。"
11
+ disable_success: "已关闭本群播报。"
12
+ args_missing: "参数缺失。"
13
+ update_success: "更新群信息成功。"
14
+ nickname_set: "昵称已设置为 {0}。"
package/src/service.ts ADDED
@@ -0,0 +1,162 @@
1
+ import { Context, Service } from 'koishi'
2
+ import { Config } from './index'
3
+ import cheerio from 'cheerio'
4
+
5
+ // Constants
6
+ const STEAM_ID_OFFSET = BigInt('76561197960265728')
7
+
8
+ export interface PlayerSummary {
9
+ steamid: string
10
+ personaname: string
11
+ profileurl: string
12
+ avatar: string
13
+ avatarmedium: string
14
+ avatarfull: string
15
+ personastate: number
16
+ gameextrainfo?: string
17
+ lastlogoff?: number
18
+ }
19
+
20
+ export interface SteamProfile {
21
+ steamid: string
22
+ player_name: string
23
+ avatar: string | Buffer
24
+ background: string | Buffer
25
+ description: string
26
+ recent_2_week_play_time: string
27
+ game_data: GameData[]
28
+ }
29
+
30
+ export interface GameData {
31
+ game_name: string
32
+ game_image: string | Buffer
33
+ play_time: string
34
+ last_played: string
35
+ achievements: Achievement[]
36
+ completed_achievement_number?: number
37
+ total_achievement_number?: number
38
+ }
39
+
40
+ export interface Achievement {
41
+ name: string
42
+ image: string | Buffer
43
+ }
44
+
45
+ export class SteamService extends Service {
46
+ constructor(ctx: Context, public config: Config) {
47
+ super(ctx, 'steam', true)
48
+ }
49
+
50
+ async getSteamId(input: string): Promise<string | null> {
51
+ if (!/^\d+$/.test(input)) return null
52
+ const id = BigInt(input)
53
+ if (id < STEAM_ID_OFFSET) {
54
+ return (id + STEAM_ID_OFFSET).toString()
55
+ }
56
+ return input
57
+ }
58
+
59
+ async getPlayerSummaries(steamIds: string[]): Promise<PlayerSummary[]> {
60
+ if (steamIds.length === 0) return []
61
+
62
+ // Split into chunks of 100
63
+ const chunks: string[][] = []
64
+ for (let i = 0; i < steamIds.length; i += 100) {
65
+ chunks.push(steamIds.slice(i, i + 100))
66
+ }
67
+
68
+ const players: PlayerSummary[] = []
69
+ for (const chunk of chunks) {
70
+ // Try keys
71
+ for (const key of this.config.steamApiKey) {
72
+ try {
73
+ const url = `http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=${key}&steamids=${chunk.join(',')}`
74
+ const data = await this.ctx.http.get(url)
75
+ if (data?.response?.players) {
76
+ players.push(...data.response.players)
77
+ break // Success for this chunk
78
+ }
79
+ } catch (e) {
80
+ this.ctx.logger('steam').warn(`API key ${key} failed: ${e}`)
81
+ }
82
+ }
83
+ }
84
+ return players
85
+ }
86
+
87
+ async getUserData(steamId: string): Promise<SteamProfile> {
88
+ const url = `https://steamcommunity.com/profiles/${steamId}`
89
+ // Cookie for timezone
90
+ const headers = {
91
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
92
+ 'Cookie': 'timezoneOffset=28800,0'
93
+ }
94
+
95
+ const html = await this.ctx.http.get(url, { headers })
96
+ const $ = cheerio.load(html)
97
+
98
+ const player_name = $('.actual_persona_name').text().trim() || $('title').text().replace('Steam 社区 :: ', '')
99
+ const description = $('.profile_summary').text().trim().replace(/\t/g, '')
100
+
101
+ let background = ''
102
+ const bgStyle = $('.no_header.profile_page').attr('style') || ''
103
+ const bgMatch = bgStyle.match(/background-image:\s*url\(\s*['"]?([^'" ]+)['"]?\s*\)/)
104
+ if (bgMatch) background = bgMatch[1]
105
+
106
+ const avatar = $('.playerAvatarAutoSizeInner > img').attr('src') || ''
107
+ const recent_2_week_play_time = $('.recentgame_quicklinks.recentgame_recentplaytime > div').text().trim()
108
+
109
+ const game_data: GameData[] = []
110
+ $('.recent_game').each((i: number, el: any) => {
111
+ const game_name = $(el).find('.game_name').text().trim()
112
+ const game_image = $(el).find('.game_capsule').attr('src') || ''
113
+ const details = $(el).find('.game_info_details').text()
114
+
115
+ const play_time_match = details.match(/总时数\s*([\d\.]+)\s*小时/)
116
+ const play_time = play_time_match ? play_time_match[1] : ''
117
+
118
+ const last_played_match = details.match(/最后运行日期:(.*) 日/)
119
+ const last_played = last_played_match ? `最后运行日期:${last_played_match[1]} 日` : '当前正在游戏'
120
+
121
+ const achievements: Achievement[] = []
122
+ $(el).find('.game_info_achievement').each((j: number, achEl: any) => {
123
+ if ($(achEl).hasClass('plus_more')) return
124
+ achievements.push({
125
+ name: $(achEl).attr('data-tooltip-text') || '',
126
+ image: $(achEl).find('img').attr('src') || ''
127
+ })
128
+ })
129
+
130
+ const summary = $(el).find('.game_info_achievement_summary').find('.ellipsis').text()
131
+ let completed_achievement_number = 0
132
+ let total_achievement_number = 0
133
+ if (summary) {
134
+ const parts = summary.split('/')
135
+ if (parts.length === 2) {
136
+ completed_achievement_number = parseInt(parts[0])
137
+ total_achievement_number = parseInt(parts[1])
138
+ }
139
+ }
140
+
141
+ game_data.push({
142
+ game_name,
143
+ game_image,
144
+ play_time,
145
+ last_played,
146
+ achievements,
147
+ completed_achievement_number,
148
+ total_achievement_number
149
+ })
150
+ })
151
+
152
+ return {
153
+ steamid: steamId,
154
+ player_name,
155
+ avatar,
156
+ background,
157
+ description,
158
+ recent_2_week_play_time,
159
+ game_data
160
+ }
161
+ }
162
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "commonjs",
4
+ "target": "es2020",
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "declaration": true,
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "moduleResolution": "node"
12
+ },
13
+ "include": [
14
+ "src/**/*"
15
+ ]
16
+ }