feat: sync Telegram Login OIDC provider

This commit is contained in:
A 2026-07-21 15:46:24 +08:00
parent 30774f8c39
commit ebead9e98c
63 changed files with 11374 additions and 37 deletions

View file

@ -12,6 +12,7 @@ import (
"go.uber.org/zap"
telegramloginapp "telesrv/internal/app/telegramlogin"
"telesrv/internal/branding"
"telesrv/internal/domain"
)
@ -33,6 +34,9 @@ const (
botFatherCmdSetInlineFB = "setinlinefeedback"
botFatherCmdSetJoinGroups = "setjoingroups"
botFatherCmdSetPrivacy = "setprivacy"
botFatherCmdSetLogin = "setlogin"
botFatherCmdLoginInfo = "logininfo"
botFatherCmdResetLogin = "resetloginsecret"
botFatherStepName = "name"
botFatherStepUsername = "username"
@ -60,6 +64,9 @@ You can control me by sending these commands:
/setinlinefeedback - change inline feedback settings
/setjoingroups - toggle whether a bot can join groups
/setprivacy - toggle a bot's group privacy mode
/setlogin - configure Telegram Login allowed URLs and signing
/logininfo - show a bot's Telegram Login configuration
/resetloginsecret - rotate a bot's OIDC Client Secret
/cancel - cancel the current operation
/help - show this message`
@ -175,6 +182,7 @@ var botFatherGlobalCommands = map[string]bool{
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
botFatherCmdSetCommands: true, botFatherCmdSetInline: true, botFatherCmdSetInlineGeo: true,
botFatherCmdSetInlineFB: true, botFatherCmdSetJoinGroups: true, botFatherCmdSetPrivacy: true,
botFatherCmdSetLogin: true, botFatherCmdLoginInfo: true, botFatherCmdResetLogin: true,
}
func (s *Service) handleBotFather(ctx context.Context, userID int64, text string) botReply {
@ -231,6 +239,9 @@ var pickerPrompts = map[string]string{
botFatherCmdSetInlineGeo: "Choose a bot to change inline location requests for. Send the bot's username:",
botFatherCmdSetJoinGroups: "Choose a bot to configure group joining for. Send the bot's username:",
botFatherCmdSetPrivacy: "Choose a bot to configure group privacy for. Send the bot's username:",
botFatherCmdSetLogin: "Choose a bot to configure Telegram Login for. Send the bot's username:",
botFatherCmdLoginInfo: "Choose a bot whose Telegram Login configuration you want to inspect:",
botFatherCmdResetLogin: "Choose a bot whose OIDC Client Secret you want to rotate:",
}
// startBotPicker 列出 owner 的 bot 并进入 choose step所有需先选 bot 的命令共用)。
@ -322,7 +333,8 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
case botFatherCmdToken, botFatherCmdRevoke,
botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy,
botFatherCmdSetLogin, botFatherCmdLoginInfo, botFatherCmdResetLogin:
return s.startBotPicker(ctx, userID, cmd)
case botFatherCmdSetInlineFB:
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
@ -351,6 +363,8 @@ func valuePrompt(cmd, username string) string {
return fmt.Sprintf("Send 'enable' to allow @%s to be added to groups, or 'disable' to prevent it.", username)
case botFatherCmdSetPrivacy:
return fmt.Sprintf("Send 'enable' to turn ON group privacy for @%s (it will only receive commands and replies), or 'disable' to let it receive all group messages.", username)
case botFatherCmdSetLogin:
return telegramLoginConfigurationPrompt(username)
default:
return "Send the new value, or /cancel."
}
@ -445,6 +459,61 @@ func (s *Service) handleChooseBot(ctx context.Context, state domain.BotChatState
}
head := fmt.Sprintf("Token for @%s has been revoked. The old token will stop working immediately. New token:\n", chosen.Username)
return tokenReply(head, token, "\n\nKeep your token secure and store it safely, it can be used by anyone to control your bot.")
case botFatherCmdLoginInfo:
defer s.clearState(ctx, state.UserID)
if s.telegramLogin == nil {
return botReply{Text: "Telegram Login is not enabled on this server."}
}
configuration, found, err := s.telegramLogin.ClientConfiguration(ctx, chosen.ID)
if err != nil {
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
if !found {
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin to create it.", chosen.Username)}
}
return botReply{Text: formatTelegramLoginConfiguration(chosen.Username, configuration)}
case botFatherCmdResetLogin:
defer s.clearState(ctx, state.UserID)
if s.telegramLogin == nil {
return botReply{Text: "Telegram Login is not enabled on this server."}
}
credentials, err := s.telegramLogin.RotateClientSecret(ctx, chosen.ID)
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Use /setlogin first.", chosen.Username)}
}
if err != nil {
s.log.Error("botfather: rotate telegram login secret", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
head := fmt.Sprintf("The previous OIDC Client Secret for @%s is now invalid. Save this new secret; it will only be shown once:\n", chosen.Username)
return tokenReply(head, credentials.Secret, "\n\nClient ID: "+credentials.Client.ClientID)
case botFatherCmdSetLogin:
if s.telegramLogin == nil {
s.clearState(ctx, state.UserID)
return botReply{Text: "Telegram Login is not enabled on this server."}
}
credentials, created, err := s.telegramLogin.EnsureClient(ctx, chosen.ID)
if err != nil {
s.log.Error("botfather: ensure telegram login client", zap.Int64("bot_user_id", chosen.ID), zap.Error(err))
return internalReply()
}
state.Step = botFatherStepValue
if state.Draft == nil {
state.Draft = map[string]string{}
}
state.Draft[botFatherDraftBotID] = strconv.FormatInt(chosen.ID, 10)
state.Draft[botFatherDraftBotUsername] = chosen.Username
if err := s.bots.UpsertBotChatState(ctx, state); err != nil {
s.log.Error("botfather: save telegram login state", zap.Int64("user_id", state.UserID), zap.Error(err))
return internalReply()
}
prompt := telegramLoginConfigurationPrompt(chosen.Username)
if !created {
return botReply{Text: fmt.Sprintf("Telegram Login client %s is ready for @%s.\n\n%s", credentials.Client.ClientID, chosen.Username, prompt)}
}
head := fmt.Sprintf("Telegram Login is now enabled for @%s.\nClient ID: %s\nSave this Client Secret; it will only be shown once:\n", chosen.Username, credentials.Client.ClientID)
return tokenReply(head, credentials.Secret, "\n\n"+prompt)
case botFatherCmdSetName, botFatherCmdSetDescription, botFatherCmdSetAbout,
botFatherCmdSetCommands, botFatherCmdSetInline, botFatherCmdSetInlineGeo,
botFatherCmdSetJoinGroups, botFatherCmdSetPrivacy:
@ -507,6 +576,8 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
reply, err = s.applyToggle(ctx, botID, text, true)
case botFatherCmdSetPrivacy:
reply, err = s.applyToggle(ctx, botID, text, false)
case botFatherCmdSetLogin:
reply, err = s.applyTelegramLoginConfiguration(ctx, botID, username, text)
default:
s.clearState(ctx, state.UserID)
return internalReply()
@ -583,6 +654,156 @@ func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text strin
return botReply{Text: fmt.Sprintf("Success! Inline location requests are now %s.", state)}, nil
}
func telegramLoginConfigurationPrompt(username string) string {
return fmt.Sprintf(`Send one configuration command for @%s:
add origin https://example.com
add redirect https://example.com/auth/callback
add ios com.example.app ABCDE12345 exampleapp://tglogin Example iOS App
add android com.example.app AA:BB:...:FF exampleapp://telegram-login Example Android App
remove origin https://example.com
remove redirect https://example.com/auth/callback
remove app 12
algorithm RS256|ES256|EdDSA|ES256K
enable
disable
Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Run /logininfo to inspect the result or /cancel to stop.`, username)
}
func formatTelegramLoginConfiguration(username string, configuration telegramloginapp.ClientConfiguration) string {
status := "disabled"
if configuration.Client.Enabled {
status = "enabled"
}
var out strings.Builder
fmt.Fprintf(&out, "Telegram Login for @%s\nClient ID: %s\nStatus: %s\nSigning algorithm: %s\nSecret version: %d",
username, configuration.Client.ClientID, status, configuration.Client.SigningAlgorithm, configuration.Client.SecretVersion)
if len(configuration.AllowedURLs) == 0 {
out.WriteString("\nAllowed URLs: none")
} else {
out.WriteString("\nAllowed URLs:")
for _, allowed := range configuration.AllowedURLs {
fmt.Fprintf(&out, "\n- %s %s", allowed.Kind, allowed.NormalizedURL)
}
}
if len(configuration.NativeApps) > 0 {
out.WriteString("\nNative apps:")
for _, app := range configuration.NativeApps {
fmt.Fprintf(&out, "\n- #%d %s %s [%s] -> %s (%s)", app.ID, app.Platform, app.ApplicationID, app.VerificationID, app.CallbackURI, app.VerifiedDisplayName)
}
}
return out.String()
}
func telegramLoginAllowedURLKind(raw string) (domain.TelegramLoginAllowedURLKind, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "origin":
return domain.TelegramLoginAllowedWebOrigin, true
case "redirect":
return domain.TelegramLoginAllowedRedirectURI, true
default:
return "", false
}
}
func telegramLoginSigningAlgorithm(raw string) (domain.TelegramLoginSigningAlgorithm, bool) {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "RS256":
return domain.TelegramLoginSigningRS256, true
case "ES256":
return domain.TelegramLoginSigningES256, true
case "EDDSA":
return domain.TelegramLoginSigningEdDSA, true
case "ES256K":
return domain.TelegramLoginSigningES256K, true
default:
return "", false
}
}
func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int64, username, text string) (botReply, error) {
if s.telegramLogin == nil {
return botReply{Text: "Telegram Login is not enabled on this server."}, domain.ErrTelegramLoginClientDisabled
}
fields := strings.Fields(strings.TrimSpace(text))
if len(fields) == 1 {
switch strings.ToLower(fields[0]) {
case "enable":
if err := s.telegramLogin.SetClientEnabled(ctx, botID, true); err != nil {
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s. Use /setlogin for another change or /logininfo to review it.", username)}, nil
case "disable":
if err := s.telegramLogin.SetClientEnabled(ctx, botID, false); err != nil {
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Telegram Login is disabled for @%s. Pending requests can no longer be approved or exchanged.", username)}, nil
}
}
if len(fields) == 2 && strings.EqualFold(fields[0], "algorithm") {
algorithm, ok := telegramLoginSigningAlgorithm(fields[1])
if !ok {
return botReply{Text: "Unknown signing algorithm. Use RS256, ES256, EdDSA or ES256K, or /cancel."}, domain.ErrTelegramLoginClientInvalid
}
if _, err := s.telegramLogin.SetClientSigningAlgorithm(ctx, botID, algorithm); err != nil {
if errors.Is(err, domain.ErrTelegramLoginClientInvalid) {
return botReply{Text: fmt.Sprintf("%s is not available on this server because no active signing key is configured for it. Choose another algorithm or ask the operator to rotate the key ring.", algorithm)}, err
}
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Success! New ID tokens for @%s will use %s. EdDSA and ES256K accept only the openid scope.", username, algorithm)}, nil
}
if len(fields) == 3 && (strings.EqualFold(fields[0], "add") || strings.EqualFold(fields[0], "remove")) &&
(strings.EqualFold(fields[1], "origin") || strings.EqualFold(fields[1], "redirect")) {
kind, ok := telegramLoginAllowedURLKind(fields[1])
if !ok {
return botReply{Text: "URL kind must be origin or redirect. Try again or /cancel."}, domain.ErrTelegramLoginURLInvalid
}
if strings.EqualFold(fields[0], "add") {
allowed, err := s.telegramLogin.AddAllowedURL(ctx, botID, kind, fields[2])
if err != nil {
return botReply{Text: "That URL is not allowed. Use an exact HTTPS URL without credentials, fragments or reserved OAuth query fields."}, err
}
return botReply{Text: fmt.Sprintf("Success! Added %s for @%s:\n%s", allowed.Kind, username, allowed.NormalizedURL)}, nil
}
deleted, err := s.telegramLogin.DeleteAllowedURL(ctx, botID, kind, fields[2])
if err != nil {
return botReply{Text: "That URL is invalid. Try again or /cancel."}, err
}
if !deleted {
return botReply{Text: "That exact URL was not registered. Check /logininfo and try again."}, domain.ErrTelegramLoginURLInvalid
}
return botReply{Text: fmt.Sprintf("Success! Removed %s from @%s.", kind, username)}, nil
}
if len(fields) >= 6 && strings.EqualFold(fields[0], "add") && (strings.EqualFold(fields[1], "ios") || strings.EqualFold(fields[1], "android")) {
platform := domain.TelegramLoginNativeIOS
if strings.EqualFold(fields[1], "android") {
platform = domain.TelegramLoginNativeAndroid
}
app, err := s.telegramLogin.AddNativeApp(ctx, botID, platform, fields[2], fields[3], fields[4], strings.Join(fields[5:], " "))
if err != nil {
return botReply{Text: "Invalid native app registration. iOS needs Bundle ID + 10-character Team ID; Android needs package name + SHA-256 signing fingerprint. Use an exact HTTPS callback or a custom scheme://host callback."}, err
}
return botReply{Text: fmt.Sprintf("Success! Registered native app #%d for @%s:\n%s %s -> %s", app.ID, username, app.Platform, app.ApplicationID, app.CallbackURI)}, nil
}
if len(fields) == 3 && strings.EqualFold(fields[0], "remove") && strings.EqualFold(fields[1], "app") {
appID, err := strconv.ParseInt(fields[2], 10, 64)
if err != nil || appID <= 0 {
return botReply{Text: "Native app ID must be the positive number shown by /logininfo."}, domain.ErrTelegramLoginClientInvalid
}
deleted, err := s.telegramLogin.DeleteNativeApp(ctx, botID, appID)
if err != nil {
return botReply{}, err
}
if !deleted {
return botReply{Text: "That native app was not registered for this bot. Check /logininfo."}, domain.ErrTelegramLoginClientInvalid
}
return botReply{Text: fmt.Sprintf("Success! Removed native app #%d from @%s.", appID, username)}, nil
}
return botReply{Text: telegramLoginConfigurationPrompt(username)}, domain.ErrTelegramLoginRequestInvalid
}
// applyToggle 解析 enable/disable 并设置 joingroupsjoin=true或 privacyjoin=false
func (s *Service) applyToggle(ctx context.Context, botID int64, text string, join bool) (botReply, error) {
var on bool