65.9K
CodeProject 正在变化。 阅读更多。
Home

TwittiBot: 开发 WPF 混合智能客户端以使用 Twitter 的冒险经历

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (12投票s)

2009 年 6 月 1 日

CPOL

14分钟阅读

viewsIcon

47183

downloadIcon

1059

一篇关于使用 Windows Presentation Foundation 和 TwitterLib 类库创建 Twitter 混合智能客户端的文章

twittibot_sm.jpg

目录

引言

本文演示了 TwittiBot,一位程序员首次尝试学习和使用 Windows Presentation Foundation 和 TwitterLib 类库来为 Twitter 创建一个混合智能客户端。

项目中包含源代码;该解决方案是在 Windows XP SP 2 上使用 Visual Studio 2008、.NET Framework 3.5 和 SQL Server 2000 创建的。

背景,或给盖茨先生一个拥抱

尽管我编程已经有十一年多了,但我以前从未向拥有大量受众的网站发布过任何内容(更不用说技术文章了),除非你算上我曾给 MSDN 现已停刊的技术专栏“Web Team Talking”投稿了俳句诗。来吧,我敢让你谷歌搜索“web men talking haiku”;如果你点击搜索结果的第一个链接,你会找到我写下的这篇意味深长的作品

我的 ActiveX 文档
在浏览器中显示。

终于,它奏效了!

但我离题了。

我目前是一家财富 50 强医疗保健公司的一名远程办公的 Web 应用程序开发人员。我可以在家工作,在一个帮助人们过上更健康生活的行业工作,并且我还能玩转酷炫的技术工具。

史上最好的工作。

我们的团队标准化使用 .NET,但更重要的是,我是一个微软迷,并且我对此毫不羞愧。正是因为微软,我才能找到这份工作。如果有一天我见到比尔·盖茨,我会走到他面前说:“盖茨先生,感谢您多年来的辛勤工作。因为您所做的一切,我才能谋生,我想亲自表达我的感激之情。”

然后我会给他一个大大的拥抱。

实际上,我还有大约十几个人应该感谢,包括我早期职业生涯的编程导师,我的好朋友 Big Dan Zumwalt。别担心,他已经收到过我的拥抱了。但同样,我离题了。

这是什么东西?Twitter 的组合

我曾隐约听说过 MySpace、Facebook 和 Twitter 等社交网站(我所有的家人和朋友都活在现实世界;我还需要虚拟朋友吗?),而且我最近听说 Twitter 是增长最快的社交网站之一。所以,有一天我浏览了那个网站,更多是出于对 Twitter API 的好奇,而不是想和陌生人聊天(我想这证实了我是一名货真价实的、头脑简单的技术宅)。

我读了足够多的关于这个网站的信息,知道它与你的浏览器中的 SMS(短信服务)有关。老实说,整个 Twitter 的想法有点让我恼火,因为我有两个女儿,一个 22 岁,一个 18 岁,而那 140 个字符的短信发送方式让我想起了她们几乎不停的短信聊天。我想我一定是老一代人了:婴儿潮的末尾,而不是 Y 世代。

乍一看,Twitter 似乎不过是数字时代的 140 个字符的在线聊天。但我慢慢注意到,在调情和表情符号之间,有些用户在广播一些真正有用的信息。我读到的“推文”(Twitter 帖子)越多,我就越确信 Twitter 用户正在定义一个全新的发布平台,一种拥有 140 个字符标题的数字报摊。

作为 Twitter 用户,其中一个目标是让其他用户订阅或“关注”你的更新。如果我发布常规更新,其他 Twitter 用户有可能关注我吗?如果可能,我需要多久发布一次?一小时一次?两次?三次?我意识到频繁发布是可取的;越频繁越好。而 Twitter 用户群大致围绕着“物以类聚”或特殊兴趣小组进行组织。奇怪的是,有一个特殊兴趣小组是由那些对异性有特殊兴趣的 Twitter 用户组成的。如果我提供关于关系、约会、婚姻、离婚等网络信息的 URL,其他成员有可能关注我吗?

所以,我脑海中那个小小的程序员声音(你也有一个,对吧?)说:

“我才不会浪费时间手动发布到网站上。但如果我能创建一个从数据库抓取信息的自动化进程……”

你很熟悉那种感觉,对吧?那种奇怪的、奇妙的灵感涌现的感觉,愿景在你脑海中形成:看到自己创建数据库、编写应用程序、在表单上放置控件。我立即开始着手一个概念验证。

敏捷:我就是这样做的

因此,最初的、小小的目标只是创建代码来生成一条包含 URL 和描述的“推文”。一旦完成,下一阶段就是将发布自动化,使其以五分钟的间隔进行,并从数据库中随机检索描述和 URL。作为测试/调试过程的一部分,我还希望代码在间隔之间显示一个倒计时,并在每次达到一个间隔时更新一个运行状态。

我每天都使用 Visual Basic,所以自然而然地,我开始用 VB.NET 编写概念验证。(对于混合智能客户端,我决定切换到 C#,所以本文中的代码片段是用 C# 编写的。)

第一个挑战是编写 Twitter 自动化代码。在注册了 Twitter 账号后,我很快发现 Twitter 为开发者提供了 API。阅读文档有点令人望而生畏,因为它涉及到直接与 XML 交互,而我对 XML 的经验很少。快速谷歌搜索后,我找到了 Bruno Piovan 的博客文章,他在其中详细介绍了如何使用 Microsoft 的 Web 客户端更新他的 Twitter 账号。 Eureka!我复制了他的代码,并得到了一个可以工作的概念验证!即使我是一个人,我仍在以敏捷的迭代方式开发我的应用程序!

尽管如此,我仍然感到有些难以满足。概念验证成功了,但不知何故,它似乎不够“酷”。就个人而言,在使用 API 时,我更喜欢使用某种包装类或库来抽象 API 的细节(好吧,我就是懒惰。)理想情况下,该类库应该专门为目标 API 设计。是否有这样的东西,最好是 .NET 版本的,用于 Twitter API?

“……你找到 TwitterLib 了吗?来我怀里吧,我那闪闪发光的男孩!”

我被一些提及神秘“TwitterLib”DLL 的谷歌搜索结果所吸引,并且在做了一些进一步的研究后,我发现了 Rod Paddock 的信息性文章(在另一个当然不便提及的网站上),其中详细介绍了该库的使用以及一些示例代码。“太酷了!”我想,“确实有一个库!那么,我该在哪里下载它呢?”Paddock 先生的文章只是以 tantalizing 的前景逗弄我,但遗憾的是没有提供 TwitterLib 的链接。

现在,你可以看出谷歌是我永恒的朋友。我终于在一个名为 Alan Le 的博客的帖子中发现了这一点,他在其中分享了他为他的“Witty”Twitter 客户端创建 Twitter API 包装类的历程,他将其命名为“TwitterLib”。谢谢您,Le 先生!

提示:要使用 TwitterLib,请下载并安装 Witty,然后将 *TwitterLib.dll* 文件复制到你的 Visual Studio 项目中并设置引用。

除了 Rod Paddock 略显稀疏的示例代码外,我几乎找不到其他文章记录 TwitterLib 的类和方法,而且我肯定没有精力去研究 Google Code 上的 TwitterLib 源代码。

所以,这意味着需要实验。经过一番试错,我的努力得到了回报。这是我使用 TwitterLib 进行 Twitter 发送的代码。

//update Twitter using Twitternet class from TwitterLib
private void UpdateTwitter()
{
    //use secure string object to encrypt password
    SecureString ssPassword = new SecureString();
    TwitterNet oTwittiClient = default(TwitterNet);

    try
    {
        //encrypt password
        foreach (char chCharacter in mstrPassword.ToCharArray())
        {
            ssPassword.AppendChar(chCharacter);
        }

        //instantiate new twitter client
        oTwittiClient = new TwitterNet(mstrUserName, ssPassword);

        //login
        oTwittiClient.Login();

        //set the client name; default is "Witty"
        oTwittiClient.ClientName = "The TwittiBot";

        //tweet
        oTwittiClient.AddTweet(mstrTweetText.ToString());
    }
    catch (Exception ex)
    {
       mstrErrorMessage += ex.Message.ToString();
    }
}

Le 先生使用了 `SecureString` 类,这让我赶紧去做了更多研究,但除此之外,TwitterLib 运行得非常出色!

计时器、倒计时和代码转换……我的天哪!

编写混合智能客户端要克服的第二个挑战是将 VB.NET 转换为 C#,因为我完全忘记了这两种语言有多么不同。举个例子:一个简单的计时器。在 VB 中,你只需将一个计时器控件拖放到设计器表面,搞定。但在 C# 中则不然。在谷歌搜索“C# timer”并查看了相关文章后,我使用 `DispatcherTimer` 类敲出了以下代码。

  • StartTimer。初始化我们的 `DispatcherTimer` 和倒计时。
  • Main。由 `DispatcherTimer` 驱动,我们的主子例程负责五分钟倒计时并更新 Twitter。
  • InitializeFiveMinuteCountdown。在启动时调用,此函数返回一个整数,表示距离下一个五分钟间隔剩余的秒数。
  • GetNextFiveMinuteInterval。给定当前的小时分钟数,它会计算最近的五分钟增量。
  • LoadTimeLine。填充将绑定到列表框的推文集合。
public partial class Window1 : Window
{
    #region "Private Member Variables"
        private DispatcherTimer timer;
        private bool blnIntervalReached;
        private int iFiveMinuteCountdown;
        private string mstrErrorMessage;
        private Twittibot oTwittiBot;
    #endregion

    #region "Public Constructors"
        public Window1()
        {
            InitializeComponent();
            LoadTimeLine();    
        }

    #endregion

    #region "Public Methods"

        public void Timer_Tick(object sender, EventArgs eArgs)
        {
            if (sender == timer)
            {
	  	        //main routine
                Main();
            }
        }

        /// <summary>
        /// calculates the number of seconds until the next five minute
        /// increment of the hour
        public int InitializeFiveMinuteCountdown(DateTime StartDate)
        {
            DateTime dtStartDateTime = default(DateTime);
            int iNextFiveMinuteInterval = 0;
            int iSecondsCountDown = 0;
            TimeSpan tsNextFiveMinuteInterval = default(TimeSpan);
            int iHour = 0;

            try
            {
                //get the start date and time
                dtStartDateTime = StartDate;

                //get the next five minute interval
                iNextFiveMinuteInterval = 
			GetNextFiveMinuteInterval(dtStartDateTime.Minute);

                //check for the top of the hour
                if (iNextFiveMinuteInterval == 60)
                {
                    iHour = DateTime.Now.Hour + 1;                
                }
                else
                {
                    iHour = DateTime.Now.Hour;
                }

                //add five to the interval if it equals the current time
                if (iNextFiveMinuteInterval == DateTime.Now.Minute)
                {
                    iNextFiveMinuteInterval += 5;
                }

                //get the end time
                DateTime dtEndDateTime = new DateTime(DateTime.Now.Year, 
			DateTime.Now.Month, DateTime.Now.Day, 
			iHour, iNextFiveMinuteInterval, 0);

                //calculate the time difference and set a timespan
                tsNextFiveMinuteInterval = dtEndDateTime.Subtract(DateTime.Now);

                //get the seconds
                iSecondsCountDown = (tsNextFiveMinuteInterval.Minutes * 60) + 
				tsNextFiveMinuteInterval.Seconds;
            }
            catch (Exception ex)
            {
                mstrErrorMessage += ex.Message.ToString();
            }

            return iSecondsCountDown;
        }

    #endregion

    #region "Private Methods"
            
        /// <summary>
        /// The Main() subroutine drives our five minute countdown and 
        /// updates Twitter.
        /// </summary>
        private void Main()
        {
            lblTime.Content = DateTime.Now.ToString();
            iFiveMinuteCountdown = iFiveMinuteCountdown - 1;
            lblFiveMinuteCountdown.Content = iFiveMinuteCountdown;
            
            if (iFiveMinuteCountdown == 0)
            {
                txtStatus.Text += DateTime.Now.TimeOfDay.ToString() + "\r\n";
                txtStatus.Text += "5 minute interval reached." + "\r\n";
                txtStatus.Text += 
			"----------------------------------------------" + "\r\n";
                    
                oTwittiBot = new Twittibot();
                Thread oTweetThread = new Thread(oTwittiBot.Tweet);

                oTweetThread.Start();

                iFiveMinuteCountdown = 300;
            }
        } 

        /// returns the nearest five minute increment in the hour
        private int GetNextFiveMinuteInterval(int Minutes)
        {
            int result;
            int i = 0;

            try
            {
                /* spin through a loop from the value of Minutes
                 * until we reach 60
                 * or we hit a multiple of five
                 * incrementing the variable by one */
                for (i = Minutes; i <= 60; i++ )
                {
                    //use the modulus operator with 5 as a divisor
                    result = i % 5;

                    //if we get zero as remainder, we have got our next 5 minute value
                    if (result == 0)
                    {
                        return i;
                    }
                }
            }

            catch (Exception ex)
            {
                mstrErrorMessage += ex.Message.ToString();
            }

            return i;
        }

        /// <summary>
        /// returns a TimeLineItems collection and binds to list box
        /// </summary>
        private void LoadTimeLine()
        {
            Twittibot oTwittiBot = new Twittibot();
            TimeLineItems oTimeLine = oTwittiBot.GetTimeLineItems();

            lstTimeLine.ItemsSource = oTimeLine;
        }
			
        /// <summary>
        /// instantiates, initializes, and starts timer
        /// initializes countdown
        /// </summary>
        private void StartTimer()
	    {
	        timer = new DispatcherTimer();
            timer.Interval = TimeSpan.FromSeconds(1);
            timer.Tick += new EventHandler(Timer_Tick);
            timer.Start();

            lblTime.Content = DateTime.Now;

            DateTime dtStart = new DateTime(DateTime.Now.Year, 
			DateTime.Now.Month, DateTime.Now.Day, 
			DateTime.Now.Hour, DateTime.Now.Minute, DateTime.Now.Second);
            iFiveMinuteCountdown = InitializeFiveMinuteCountdown(dtStart);
            txtStatus.Text += "Running...current session started at " + 
					DateTime.Now.ToString() + "\r\n";
        }

    #endregion

    #region "Private Events"
        private void btnRefresh_Click(object sender, System.Windows.RoutedEventArgs e)
        {
            LoadTimeLine();
        }

        private void btnStart_Click(object sender, System.Windows.RoutedEventArgs e)
        {           	
            StartTimer();
        }

        private void btnStop_Click(object sender, System.Windows.RoutedEventArgs e)
        {
            timer.Stop();
        } 

    #endregion
}

我不会用我的数据访问代码来烦扰你,但源代码中包含了它,如果你想看的话(URL 和描述,你需要自己找!)。

逻辑完成后,我转向了创建 GUI 的任务。

Windows Presentation Foundation:嘿,这看起来有点熟悉……

TwittiBot Screenshot

在文章开头,我提到过我曾被“踢着屁股、尖叫着”拉去学习一项新的开发技术。这是因为在过去的五年里,我一直埋头苦干,专攻 ASP.NET。说实话,我沉迷于 Web 开发,以至于我可能无意识地决定再也不涉足 Windows 开发了。当然,我读过微软技术文章中提到“Avalon”或后来提到的“Windows Presentation Foundation”,但这些术语对我来说毫无意义。毕竟,我们程序员——也许作为一种生存机制——已经发展出了一种认知技能,称为“选择性注意”,即筛选和忽略我们认为不重要的刺激,只关注我们认为真正重要的刺激。那些新词甚至没有出现在我的雷达屏幕上,我对此追悔莫及。

然而,一旦我偶然发现了 CodeProject 的小型智能客户端竞赛,我立即开始自学 WPF。在阅读了一些网络文章,并之前在当地的 Office Depot 展出的演示笔记本上看到过 Windows Vista 运行之后,我理解了 WPF 的功能:.NET Framework 3.x 的图形子系统,旨在提供更丰富的视觉用户体验。

嗯,我已经在用 Visual Studio 2008 了,所以下一步是下载 Microsoft Expressions Blend。

提示:如果你想先试用再购买,请给自己一个方便,下载 Expressions Blend 3 的预览版;避免使用 2.0 版本。 Blend 3 的优点之一是,在编辑 XAML 时(稍后会详细介绍 XAML)可以使用 Intellisense。有没有尝试过没有 Intellisense 进行源代码编辑?太糟糕了。

当我启动 Blend 时,我注意到的第一件事是它感觉有点熟悉。在我成为开发者之前,我在 80 年代初到中期曾是一名平面设计师/技术插画师。好吧,我们都记得 Apple Macintosh 的到来和桌面出版的出现;我看到了趋势,我知道我的行业正在朝着数字方向发展。所以,我加入了 DTP 的行列,一头扎进了 Macintosh、PageMaker 和 Illustrator 的使用。随着岁月的流逝,我还掌握了 PhotoShop、Director 和一个名为 Poser 的小型 3D 程序。

我之所以提到这一点,是因为 Blend 的界面设计让我想起了在那些图形程序中工作的经历,有点像将 Photoshop、Illustrator、Director 和 Poser 的元素融为一体。我想,从某种意义上说,这是故意的,因为 Blend 的*宗旨*就是为 Web 和 Windows 创建丰富的图形界面。如果你像我一样涉足过多媒体制作,你可能会有类似的似曾相识的感觉。

寻找 Expressions Blend 快速入门课程的准备工作开始了。在随后的谷歌搜索中,我找到了一个微软 MIX07 发布会上的精巧小视频。 Celso Gomes 在发布会上的贡献尤其启发了我创建 TwittiBot 客户端界面的想法。 Rich Stern 关于创建简单的 Twitter 客户端的博客文章也非常有帮助,特别是关于 `ListBox` 元素的部分。

界面:告别“普通珍妮”

下载并安装了 Blend,并进行了一些教程后,我现在着手让 TwittiBot 变得漂亮。

我的主要目标很明确:根据竞赛规则,我的混合智能客户端必须“消耗某种基于 Web 的数据源”。嗯,我认为 Twitter 时间线可以算作某种基于 Web 的数据源,所以我决定在我的 GUI 设计中加入一个简单的列表框元素来显示时间线,其模式参考了 Rich Stern 的 Twitter 客户端样本中的数据绑定列表框。

所以,就像上图所示,我想显示用户最新的推文更新,包括图片、屏幕名称、推文文本和相对日期,在一个项目列表中。然而,Rich Stern 的 `listbox` 示例使用了 Microsoft 的 `WebClient` 和 `NetworkCredential` 类来消费 Twitter 时间线的 XML,而我则采用了 Andrew Le 的 `TwitterLib` 类库,它完全将 XML 的复杂性从我这里抽象出来了。而且,虽然 `TwitterLib` 的 Tweet Collection 提供了带有 Twitter ID、屏幕名称和推文文本的 Tweet 项目,但包含用户图片 URL 的属性却在一个完全不同的类,`*User*` 中。

我决定采用 Rich 的解决方案的一个变体,即创建一个我自己的自定义 `Item` 类,包含所有必要的属性,一个配套的 `Collection` 类,以及一个填充每个 Tweet 项的所有属性并返回一个 Tweet 集合以绑定到列表的方法。以下是我自定义类的代码。

public class TimeLineItem
{
    #region "Private Member Variables"
        protected int mintID;
        protected string mstrTweetText;
        protected string mstrScreenName;
        protected string mstrImageURL;
        protected string mstrRelativeTime;
    #endregion
     #region "Public Properties"
        public int ID
        {
            get
            {
                return mintID;
            }
             set
            {
                mintID = value;
            }
        }
        public string ImageURL
        {
            get
            {
                return mstrImageURL;
            }
             set
            {
                mstrImageURL = value;
            }
        }
        public string TweetText
        {
            get
                {
                    return mstrTweetText;
                }
             set
                {
                    mstrTweetText = value;
                }
        }
        public string ScreenName
        {
            get
            {
                return mstrScreenName;
            }
             set
            {
                mstrScreenName = value;
            }
        }
        public string RelativeTime
        {
            get
            {
                return mstrRelativeTime;
            }
             set
            {
                mstrRelativeTime = value;
            }
        }
     #endregion
}
 public class TimeLineItems : CollectionBase
{
    public TimeLineItem this[int index]
    {
        get
        {
            return ((TimeLineItem)List[index]);
        }
         set
        {
            List[index] = value;
        }
    }
     public int Add(TimeLineItem oTimeLineItem)
    {
        return (List.Add(oTimeLineItem));
    }
     public int IndexOf(TimeLineItem oTimeLineItem)
    {
        return (List.IndexOf(oTimeLineItem));
    }
     public void Insert(int index, TimeLineItem oTimeLineItem)
    {
        List.Insert(index, oTimeLineItem);
    }
     public void Remove(TimeLineItem oTimeLineItem)
    {
        List.Remove(oTimeLineItem);
    }
     public bool Contains(TimeLineItem oTimeLineItem)
    {
        return (List.Contains(oTimeLineItem));
    }
}

XAML 了不起!

好像你不需要另一个提醒 XML 的无处不在,请理解这一点:Blend 以一种称为 XAML(可扩展应用程序标记语言)的 XML 方言完全描述界面元素。你只需在标签之间定义你的控件,按 F5,Blend 就会将你的 XML 编译成一个整洁的小 GUI 包。很酷,对吧?

因此,你的界面的源代码看起来就像经典的 XML 树,元素代表控件,以分层方式嵌套在一起。如果你曾经为 ASP.NET 开发创建过 Web 窗体,这也不会完全陌生。

列表框本身很简单,遵循 Rich 的教程。下面的 XAML 中,值得关注的是 "{Binding}" 属性,特别是图像和文本块控件。如果你将下面的 XAML 与上面代码中 `TimeLineItem` 类的 `public` 属性进行比较,你会注意到类属性与 XAML 数据绑定元素完全匹配。这是 `listbox` 的 XAML。

 <ListBox Background="Transparent" IsSynchronizedWithCurrentItem="True" 
	Margin="22.144,35.277,23.76,70" Grid.Row="1" x:Name="lstTimeLine">
    <ListBox.ItemTemplate>
        <DataTemplate>
            <DockPanel MaxHeight="75" 
		MaxWidth="{Binding ElementName=lstTimeline, Path=ActualWidth}">
                <Border Margin="5" DockPanel.Dock="Left" 
		BorderBrush="White" BorderThickness="1" Height="48" 
		Width="48" HorizontalAlignment="Center">
                    <Image Source="{Binding ImageURL, IsAsync=True}" 
			Height="48" Width="48" VerticalAlignment="Top" />
                </Border>
                <StackPanel Margin="5" VerticalAlignment="Top" DockPanel.Dock="Right">
                    <TextBlock Foreground="White" Text="{Binding ScreenName}" 
			HorizontalAlignment="Left" FontFamily="Trebuchet" 
			FontWeight="Bold"></TextBlock>
                    <TextBlock Foreground="White" Text="{Binding TweetText}" 
			HorizontalAlignment="Left" FontFamily="Trebuchet" 
			FontSize="9" TextWrapping="WrapWithOverflow" Width="200">
			</TextBlock>
                    <TextBlock Foreground="White" Text="{Binding RelativeTime}" 
			HorizontalAlignment="Left" FontFamily="Trebuchet" 
			FontSize="9" FontStyle="Italic" 
			TextWrapping="WrapWithOverflow" Width="200"></TextBlock>
                </StackPanel>
            </DockPanel>
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>

而数据绑定代码简直太简单了:将一个集合设置为 `listbox` 的 `ItemSource`。

/// <summary>
/// returns a TimeLineItems collection and binds to list box
/// </summary>
private void LoadTimeLine()
{
    Twittibot oTwittiBot = new Twittibot();
    TimeLineItems oTimeLine = oTwittiBot.GetTimeLineItems();

    lstTimeLine.ItemsSource = oTimeLine;
}

结论

如果你和我一样(直到最近)还没有涉足 Windows Presentation Foundation,那就对自己好一点:潜心研究吧。WPF 是一种非常有趣的体验,它相对容易上手(希望我的小入门指南会有帮助!),而且毫无疑问,你大量的 .NET 开发经验和专业知识将轻松地转移到这个令人兴奋的新领域。如果你有兴趣涉足 Twitter 编程,并希望有一个类库来处理 XML 和与 `Twitter` API 的交互,我强烈推荐 `TwitterLib`(现在你知道在哪里可以找到它了!)。

从我文章的篇幅来看,可能看起来我学到了很多东西;实际上,这次经历只让我自己证明了我究竟有多无知。微软继续其对“下一个大事件”永无止境的追求,我已经接受了这样一个现实:我永远不会停留在开发技术的尖端、沾满鲜血的刀刃上。

但是,没关系;事实就是如此。归根结底,我对自己拥有有市场技能和一份工作感到很幸运;所以,最终分析,我仍然非常感激。

如果我见到他,盖茨先生仍然会得到他的拥抱。

历史

  • 2009 年 5 月 29 日:初始版本
© . All rights reserved.